HarperFast / HarperFast/harper
Add ConversationResource for agent memory and conversation state
- Dominant language
- JavaScript
- Stars
- 89
- Forks
- 10
- Avg merge
- 2d 6h
- Merged PRs (30d)
- 200
Description
## Context
We would like to broadly support agentic data like working memory, conversation history, derived facts, embeddings of past interactions, durable task state, audit trails of decisions and actions" as native runtime properties.
Today these are buildable on tables but there is no opinionated primitive. Every team writing an agent on Harper would invent their own conversation schema, compaction policy, and recall logic. This issue proposes `ConversationResource` as the standard primitive, used by:
- The model-access API (#510) via `GenerateOpts.conversationId` binding.
- The embedded agent component ([HarperFast/discussions#502](https://github.com/orgs/HarperFast/discussions/502)).
- User apps building chat / agent surfaces directly.
## Data model (three auto-created tables)
```ts
// conversations
{
id: ULID // @primaryKey
tenant: string
owner: string // user or agent id
title: string? // optional, human or LLM-assigned
participants: string[] // actor ids
metadata: JSON // app-specific
created: timestamp
updated: timestamp
summarized_through: turnId? // last turn included in latest summary
ttl: number? // seconds; hard delete on expiry
}
// turns
{
id: ULID // @primaryKey
conversation: → conversations.id // @indexed
actor: string // user id, agent id, tool id
role: 'user' | 'assistant' | 'system' | 'tool'
content: string | StructuredContent
tool_calls: ToolCall[]?
tool_results: ToolResult[]?
tokens: { prompt, completion }?
model: string?
adapter: string?
embedding: Vector // @embed(source: "content", model: "default")
metadata: JSON
created: timestamp
superseded_by: turnId? // for edits/regenerations
}
// conversation_facts (optional, opt-in per conversation)
{
id: ULID // @primaryKey
conversation: → conversations.id // @indexed
subject: string
predicate: string
object: string
source_turn: → turns.id
confidence: number
embedding: Vector // @embed(source: "object", model: "default")
created: timestamp
}
```
## API
```ts
class ConversationResource extends Resource {
async append(turn: TurnInput): Promise;
async history(opts?: { limit?, before?, includeSummaries? }): Promise;
async recall(query: string, opts?: { k?, scope? }): AsyncIterable;
async summarize(through?: turnId): Promise;
async extractFacts(through?: turnId): Promise;
async compact(policy?: CompactionPolicy): Promise;
// Canonical primitive — assembles a model-ready message list
async buildContext(opts: {
tokenBudget: number;
recentTurns?: number; // verbatim recent turns
semanticRecall?: { k, threshold };
includeFacts?: boolean;
filterParticipants?: string[];
}): Promise;
}
```
`buildContext()` is what `scope.models.generate()` calls when `conversationId` is set. It:
1. Pulls the last N turns verbatim.
2. Includes the rolling summary for older turns.
3. Optionally injects semantically-recalled turns and facts via vector search.
4. Stays under `tokenBudget`.
## Compaction
Default policy: when conversation exceeds **50 turns** OR **8K cumulative content tokens**, summarize the oldest unsummarized half, advance `summarized_through`. Old turns stay in the table for audit; `buildContext()` uses the summary instead.
Configurable per conversation:
```ts
metadata.compaction = {
triggerTurns: 50,
triggerTokens: 8000,
summarizerModel: 'fast',
strategy: 'rolling' | 'hierarchical' | 'never'
}
```
## Multi-actor / multi-agent
Multiple agents and humans append to the same conversation; each turn carries `actor + role`. `buildContext()` can filter by participant for per-agent views of a shared transcript. Agent-to-agent coordination = two agents writing to a shared conversation; live updates flow through the existing subscription mechanism on the conversation id — no new infrastructure.
## Integration points
- **model-access API** (#510): `GenerateOpts.conversationId` → auto-append + `buildContext()`.
- **`@export`**: `ConversationResource` is auto-exposed via REST and MCP.
- **MCP** ([HarperFast/mcp-server](https://github.com/HarperFast/mcp-server)): conversations are MCP resources; turns are MCP messages; `recall()` is an MCP tool.
- **Audit**: every turn is in `auditStore` via the standard transaction log — audit trails come free.
- **Replication**: conversations replicate across Fabric like any table; works for edge/disconnected scenarios.
- **Subscriptions**: `transactionBroadcast` on `conversations.id` gives live-updating UIs for free.
## Privacy and data minimization
- Tenant boundary enforced by existing RBAC on the table.
- `ttl` triggers hard delete via a sweeper job.
- `extractFacts()` can run with a redaction-prompt step to strip PII before persisting.
- Per-conversation `metadata.retention = 'audit-only' | 'full' | 'ephemeral'` controls whether turn `content` is retained or only hashes after summarization.
## Streaming-append protocol
For `generateStream`:
1. `appendOpen()` creates a `pending` turn row with empty `content`.
2. Each chunk updates the row's `content` (single update transaction batched with a debounce, default 250ms — subscribers see live updates).
3. `appendCommit()` finalizes status, tokens, model.
4. Abort → `superseded_by` is set, row remains for audit.
## Dependencies
- **Hard**: `@embed` schema directive (from model-access API, #510) — turn embeddings require it.
- **Soft**: model-access API (#510) for `summarize()` / `extractFacts()` / semantic recall to use the configured embedding/generative backends. Could ship with hardcoded calls and refactor when #510 lands; cleaner to land in order.
## Related work
- Pairs with the embedded agent component ([HarperFast/discussions#502](https://github.com/orgs/HarperFast/discussions/502)) — that component should use `ConversationResource` as its store.
- Exposed via MCP through [HarperFast/mcp-server](https://github.com/HarperFast/mcp-server).
## Open decisions
1. Vector index on turn embeddings: global with conversation-id filter, or per-conversation? Suggested: global with filter — simpler and faster to start.
2. Fact extraction: opt-in cost (default off)? Suggested: yes — off by default, enabled per conversation in `metadata`.
3. Edits/regenerations: in-place mutation or `superseded_by` chain? Suggested: `superseded_by` chain — preserves audit, mirrors git-style history.
4. Cross-conversation memory: do we need a global "user memory" layer? Suggested: defer — let apps query across conversations via the standard vector index for v1.
## Acceptance
- [ ] `ConversationResource` base class lands in core alongside `Resource`.
- [ ] Three system tables (`conversations`, `turns`, `conversation_facts`) auto-create.
- [ ] `append`, `history`, `recall`, `summarize`, `compact`, `buildContext` implemented.
- [ ] Streaming-append protocol works (open/chunk/commit + subscription visibility).
- [ ] Default compaction policy triggers at 50 turns / 8K tokens.
- [ ] Auto-exposed via REST and MCP through existing `@export` mechanism.
- [ ] `GenerateOpts.conversationId` binding works end-to-end with model-access API (#510).
- [ ] Replication and audit verified — turns appear in `auditStore` and replicate to followers.
- [ ] Documented: building a chat app, building a multi-agent shared conversation, retention/TTL policy choices.
## Out of scope
- A reference chat UI widget (full-stack hosting story — separate work).
- Cross-conversation / global user memory (deferred per open-decision #4).
- Full fine-tuning of summarizer models.
Contributor guide
Assessment
This issue has not been assessed yet.