Changeset 0.22.8 (#150)

This commit is contained in:
2026-03-04 16:06:12 +00:00
parent 389e47b0f9
commit 7e26a2a261
114 changed files with 3700 additions and 7572 deletions

View File

@@ -1,584 +0,0 @@
# DESIGN-0.15.0 — Compaction
## Overview
Automatic conversation compaction. A background service monitors channels
for context pressure and triggers summarization via the utility model role,
replacing the manual "Summarize & Continue" button as the primary
compaction path. Manual summarization remains available as an explicit
user action.
Depends on: utility model role (v0.10.0), summarize handler (v0.10.2).
**Design principle: don't reinvent the wheel.** The existing
`SummarizeHandler` already has the full pipeline — path loading, summary
boundary detection, prompt construction, role resolution, tree insertion,
cursor update, usage logging. This design extracts that core logic into
a shared `compaction` package and adds a background scanner on top.
---
## 1. Extract: `compaction` Package
Move the reusable summarization pipeline out of `handlers/summarize.go`
into `server/compaction/compaction.go`. The HTTP handler becomes a thin
wrapper.
### Core type
```go
package compaction
// Service provides conversation compaction (summarization).
// Used by both the HTTP handler (manual) and the background scanner (auto).
type Service struct {
stores store.Stores
resolver *roles.Resolver
}
func NewService(stores store.Stores, resolver *roles.Resolver) *Service {
return &Service{stores: stores, resolver: resolver}
}
```
### Extracted method: `Compact`
The current `SummarizeHandler.Summarize` body becomes `Service.Compact`:
```go
type CompactRequest struct {
ChannelID string
UserID string // channel owner
TeamID *string // for role resolution
Trigger string // "manual" | "auto"
}
type CompactResult struct {
SummaryID string
SummarizedCount int
Model string
UsedFallback bool
Content string
InputTokens int
OutputTokens int
}
func (s *Service) Compact(ctx context.Context, req CompactRequest) (*CompactResult, error)
```
The method performs the same steps as today's handler:
1. Load active path via `getActivePath` (already exported within `handlers`)
2. Find summary boundary, collect messages to summarize
3. Guard: minimum 4 messages since last summary
4. Build summarization prompt
5. Call `resolver.Complete(ctx, RoleUtility, ...)`
6. Insert summary tree node with metadata
7. Update cursor
8. Log usage
The only new metadata field is `"trigger"` (`"manual"` or `"auto"`) so
the frontend can distinguish how the summary was created.
### Refactored handler
`handlers/summarize.go` shrinks to:
```go
func (h *SummarizeHandler) Summarize(c *gin.Context) {
// ownership check, rate limit check (unchanged)
// ...
result, err := h.compaction.Compact(c.Request.Context(), compaction.CompactRequest{
ChannelID: channelID,
UserID: userID,
TeamID: teamID,
Trigger: "manual",
})
// return JSON response (unchanged)
}
```
### Tree helpers
`getActivePath`, `isSummaryMessage`, `nextSiblingIndex`, `updateCursor`
are currently unexported in `handlers/tree.go`. Two options:
**Option A** — Export them from `handlers` and import in `compaction`.
Clean but creates a dependency from `compaction``handlers`.
**Option B** — Move tree helpers into a `treepath` (or similar) package
imported by both `handlers` and `compaction`.
Recommend **Option B** to keep the dependency graph clean:
`handlers``compaction``treepath``handlers`. The `treepath`
package is pure data + SQL queries with no handler logic.
```
server/
treepath/ ← NEW: extracted from handlers/tree.go
path.go (getActivePath, getPathToLeaf, getActiveLeaf)
summary.go (isSummaryMessage, summary metadata helpers)
siblings.go (getSiblingCount, getSiblings, findLeafFromMessage)
cursor.go (updateCursor, nextSiblingIndex)
compaction/ ← NEW
compaction.go (Service, Compact)
scanner.go (Scanner, background loop)
estimator.go (token estimation)
handlers/
tree.go → thin wrappers or deleted, imports treepath
summarize.go → thin HTTP wrapper, delegates to compaction.Service
completion.go → imports treepath for path building
```
---
## 2. Server-Side Token Estimation
The background scanner needs to evaluate context pressure without making
an LLM call. The frontend already does this with a `chars / 4` heuristic
(`tokens.js`). Replicate server-side:
```go
// compaction/estimator.go
// EstimateTokens returns a rough token count using the ~4 chars/token
// heuristic. Matches the frontend Tokens.estimate() for consistency.
func EstimateTokens(text string) int {
return (len(text) + 3) / 4
}
// EstimatePath returns total estimated tokens for a message path,
// including system prompt overhead.
func EstimatePath(path []treepath.PathMessage, systemPromptLen int) int {
total := 0
if systemPromptLen > 0 {
total += EstimateTokens(string(make([]byte, systemPromptLen))) + 4
}
for _, m := range path {
total += EstimateTokens(m.Content) + 4 // +4 per-message overhead
}
return total
}
```
Not exact, but it's the same bar the user already sees in the UI. If we
need better accuracy later, swap in a tokenizer library without changing
the interface.
### Context budget resolution
The scanner needs `max_context` for whatever model the channel is using.
Resolution chain:
1. Channel has a `model` field → look up `model_catalog` for capabilities
2. Channel has a `provider_config_id` → use that provider's catalog entry
3. Fallback: use the utility role's model context window as a proxy (it's
the model that would be doing the summarization anyway)
4. Hard fallback: 128K tokens (conservative default)
```go
func (s *Scanner) getContextBudget(ctx context.Context, ch *models.Channel) int {
if ch.Model != "" {
if caps, err := s.stores.Catalog.GetCapabilities(ctx, ch.Model); err == nil {
if caps.MaxContext > 0 {
return caps.MaxContext
}
}
}
return DefaultContextBudget // 128000
}
```
---
## 3. Background Scanner
Follows the Ingester pattern from `knowledge/ingest.go`: goroutine pool
with semaphore, WaitGroup for graceful shutdown.
### Scanner type
```go
// compaction/scanner.go
type Scanner struct {
service *Service
stores store.Stores
sem chan struct{}
wg sync.WaitGroup
stopCh chan struct{}
ticker *time.Ticker
}
type ScannerConfig struct {
Enabled bool // global kill switch
Interval time.Duration // scan frequency (default: 5 minutes)
Concurrency int // max parallel compactions (default: 2)
}
func NewScanner(svc *Service, stores store.Stores, cfg ScannerConfig) *Scanner
func (sc *Scanner) Start()
func (sc *Scanner) Stop() // signals stop + drains in-flight
```
### Scan loop
```
every <interval>:
if !globalEnabled(): continue
candidates = findCandidates()
for each candidate:
sem.acquire()
go compact(candidate)
```
### Candidate query
A single SQL query finds channels that need compaction:
```sql
SELECT c.id, c.user_id, c.model, c.settings,
COUNT(m.id) as msg_count,
SUM(LENGTH(m.content)) as total_chars
FROM channels c
JOIN messages m ON m.channel_id = c.id
AND m.deleted_at IS NULL
WHERE c.deleted_at IS NULL
AND c.type = 'direct' -- only user conversations (not groups yet)
AND c.is_archived = false
-- Skip channels with very recent activity (let the user finish typing)
AND c.updated_at < NOW() - INTERVAL '2 minutes'
-- Only channels active in the last 7 days (don't compact stale channels)
AND c.updated_at > NOW() - INTERVAL '7 days'
GROUP BY c.id
HAVING COUNT(m.id) >= 10 -- minimum message count
AND SUM(LENGTH(m.content)) > 20000 -- rough: 20K chars ≈ 5K tokens minimum
ORDER BY c.updated_at DESC
LIMIT 50 -- batch size cap
```
This is a coarse filter. Each candidate then gets a precise check:
```go
func (sc *Scanner) shouldCompact(ctx context.Context, ch *models.Channel) bool {
// 1. Check channel-level opt-out
if ch.Settings != nil {
if v, ok := ch.Settings["auto_compaction"]; ok && v == false {
return false
}
}
// 2. Load active path for the channel owner
path, err := treepath.GetActivePath(ch.ID, ch.UserID)
if err != nil || len(path) < 10 {
return false
}
// 3. Find post-summary messages only
startIdx := 0
for i, m := range path {
if treepath.IsSummaryMessage(&m) {
startIdx = i + 1
}
}
postSummary := path[startIdx:]
if len(postSummary) < 8 {
return false // not enough new messages since last summary
}
// 4. Estimate tokens and check threshold
tokens := EstimatePath(postSummary, 0)
budget := sc.getContextBudget(ctx, ch)
threshold := sc.getThreshold(ch) // default 0.70
return float64(tokens) / float64(budget) >= threshold
}
```
### Threshold
Default: **0.70** (70% of context window). This is intentionally lower
than the frontend's 75% warning bar — auto-compaction should fire before
the user sees a warning, not after.
Configurable per-channel via `settings.compaction_threshold` (float) and
globally via `global_settings` key `auto_compaction_threshold`.
### Rate limiting
Auto-compaction calls use the utility role and are org-funded. They share
the existing `utility_rate_limit` budget but get a separate counter
prefix so admins can see auto vs manual usage:
```go
entry := &models.UsageEntry{
// ...
Role: &role, // "utility"
Metadata: models.JSONMap{
"trigger": "auto", // distinguishes from manual
},
}
```
If the global utility rate limit is hit, auto-compaction silently skips
the channel and retries next scan cycle. It never blocks or errors.
---
## 4. Per-Channel Configuration
Uses the existing `Channel.Settings` JSONB column. No migration needed.
| Key | Type | Default | Description |
|-----|------|---------|-------------|
| `auto_compaction` | `bool` | `true` (from global) | Enable/disable for this channel |
| `compaction_threshold` | `float` | `0.70` (from global) | Context usage ratio to trigger |
### Frontend: channel settings
Add a "Compaction" section to the channel settings panel (the gear icon
in the chat header). Two controls:
```
┌─ Compaction ──────────────────────────────────┐
│ Auto-compact [✓] │
│ Threshold [70]% of context window │
└───────────────────────────────────────────────┘
```
The manual "Summarize & Continue" button remains in the context warning
bar and works regardless of auto-compaction settings.
---
## 5. Admin Controls
New `global_settings` keys:
| Key | Type | Default | Description |
|-----|------|---------|-------------|
| `auto_compaction_enabled` | `bool` | `false` | Global kill switch. Ships **off** — admins opt in. |
| `auto_compaction_threshold` | `float` | `0.70` | Default threshold for channels without override |
| `auto_compaction_interval_minutes` | `int` | `5` | Scanner tick interval |
| `auto_compaction_concurrency` | `int` | `2` | Max parallel auto-compactions |
| `auto_compaction_cooldown_minutes` | `int` | `30` | Min time between auto-compactions of the same channel |
### Admin UI
Add an "Auto-Compaction" card to the existing admin AI settings panel
(alongside the utility rate limit and model roles):
```
┌─ Auto-Compaction ─────────────────────────────┐
│ Enabled [✓] │
│ Threshold [70]% │
│ Scan interval [5] minutes │
│ Concurrency [2] parallel │
│ Cooldown [30] minutes per channel │
└───────────────────────────────────────────────┘
```
---
## 6. Cooldown + Dedup
Prevent rapid re-compaction of the same channel:
1. **Cooldown** — after compacting channel X, don't re-evaluate it for
`cooldown_minutes`. Track in-memory:
```go
lastCompacted map[string]time.Time // channelID → timestamp
```
2. **In-flight dedup** — if channel X is currently being compacted (sem
acquired, goroutine running), skip it in the current scan. Track via:
```go
inFlight sync.Map // channelID → struct{}
```
3. **Stacking** — the existing summary boundary logic already handles
multiple summaries correctly. Auto-compaction produces the same tree
nodes as manual, so stacking "just works."
---
## 7. Lifecycle + Wiring
### main.go changes
```go
// After role resolver and stores setup:
compactionSvc := compaction.NewService(stores, roleResolver)
compactionScanner := compaction.NewScanner(compactionSvc, stores, compaction.ScannerConfig{
Enabled: getGlobalBool(stores, "auto_compaction_enabled", false),
Interval: time.Duration(getGlobalInt(stores, "auto_compaction_interval_minutes", 5)) * time.Minute,
Concurrency: getGlobalInt(stores, "auto_compaction_concurrency", 2),
})
compactionScanner.Start()
defer compactionScanner.Stop() // drain in-flight on shutdown
// Summarize handler now wraps compaction service:
summarize := handlers.NewSummarizeHandler(stores, roleResolver, compactionSvc)
```
Follows the same pattern as `kbIngester` with `defer kbIngester.Wait()`.
### Config reload
The scanner re-reads global settings at the start of each scan cycle.
No restart required to change thresholds, intervals, or the kill switch.
If `auto_compaction_enabled` flips to `false`, the scanner skips all
work on the next tick.
---
## 8. Observability
### Logging
```
🔍 compaction: scanning 50 candidates
🔍 compaction: channel abc123 qualifies (78% of 128K context, 42 messages post-summary)
📝 compaction: compacting channel abc123 for user xyz (trigger=auto)
✅ compaction: channel abc123 done (42 messages → 847 chars, model=claude-sonnet-4-5-20250929)
⏭ compaction: channel def456 skipped (cooldown, 12m remaining)
⏭ compaction: channel ghi789 skipped (rate limit)
```
### Audit log
Auto-compaction events are logged to `audit_log`:
```go
stores.Audit.Log(ctx, &models.AuditEntry{
Actor: "system:compaction",
Action: "compaction.auto",
Resource: "channel:" + channelID,
Details: models.JSONMap{
"summarized_count": result.SummarizedCount,
"model": result.Model,
"trigger": "auto",
},
})
```
### Usage tracking
All auto-compaction calls flow through the existing usage logging path
(same as manual summarization). The `metadata.trigger = "auto"` field
lets the admin usage dashboard distinguish auto from manual.
---
## 9. Frontend Changes
Minimal — the backend does the heavy lifting.
### Auto-compaction indicator
When loading a conversation that was auto-compacted, the frontend
already handles summary boundaries correctly (the collapsed history
toggle, the "X earlier messages summarized" bar). The only new UI is
a subtle indicator on the summary node:
```
▸ 42 earlier messages summarized (auto)
```
The `(auto)` label comes from `metadata.trigger` on the summary message.
### Channel settings
Add compaction controls to the channel settings panel (Section 4 above).
Saves to `PATCH /channels/:id` with `settings` field — no new endpoint.
### Admin panel
Add the auto-compaction card to the AI settings section (Section 5).
Saves via existing `PUT /admin/settings/:key` endpoints.
---
## 10. Migration
**No new migration required.**
- Channel compaction settings → existing `Channel.Settings` JSONB
- Admin settings → existing `global_settings` table
- Summary messages → existing `messages` table with metadata
- Usage tracking → existing `usage_log` table
- Audit logging → existing `audit_log` table
---
## 11. File Manifest
```
server/
treepath/ ← NEW package
path.go (extracted from handlers/tree.go)
summary.go (isSummaryMessage + helpers)
siblings.go (sibling queries)
cursor.go (cursor + sibling index)
compaction/ ← NEW package
compaction.go (Service, Compact, CompactRequest/Result)
scanner.go (Scanner, scan loop, candidate query)
estimator.go (EstimateTokens, EstimatePath)
handlers/
tree.go (updated: delegates to treepath)
summarize.go (updated: delegates to compaction.Service)
completion.go (updated: imports treepath)
main.go (updated: wire compaction scanner)
src/js/
ui-core.js (updated: show "(auto)" on auto-compacted summaries)
ui-settings.js (updated: channel compaction controls)
ui-admin.js (updated: auto-compaction admin card)
```
---
## 12. Implementation Order
**Phase 1: Extract** — Move tree helpers to `treepath`, core
summarization to `compaction.Service`. Refactor `SummarizeHandler` to
delegate. All existing tests must still pass. No new behavior.
**Phase 2: Estimator + Scanner** — Add `estimator.go`, `scanner.go`,
candidate query, `shouldCompact` logic. Wire into `main.go` with
`Start()`/`Stop()`. Global settings for admin controls. Ship with
`auto_compaction_enabled` defaulting to `false`.
**Phase 3: Frontend** — Channel compaction settings, admin panel card,
auto-compaction indicator on summary nodes.
**Phase 4: Soak + tune** — Enable on a test instance, watch logs, tune
default threshold and cooldown. Adjust candidate query filters if scan
is too expensive.
---
## 13. What This Intentionally Doesn't Do
- **Token-accurate estimation** — chars/4 is good enough. A real
tokenizer adds a dependency for marginal accuracy gain. Revisit if
users report summaries firing too early or too late.
- **Per-model prompt tuning** — the summarization prompt is one-size-fits
-all. Works fine across Claude/GPT/Llama. Revisit if quality varies
significantly across providers.
- **Group channel compaction** — auto-compaction targets `direct` channels
only (single-owner). Group/channel types need multi-cursor awareness
(which user's path to compact?). Deferred to v0.19.0 multi-participant.
- **Compaction snapshots for KB** — the roadmap mentions this. The
summary nodes we create here are already stored as messages with full
metadata. KB integration can query them later without changes to this
design.
- **Event-driven triggers** — a post-completion hook that checks
immediately after each message would reduce latency vs. the ticker.
Worth adding later but the ticker is simpler to ship and debug. The
2-minute activity gap in the candidate query achieves a similar effect
(don't compact mid-conversation).

View File

@@ -1,332 +0,0 @@
# DESIGN-0.15.1 — Context Recall Tools
## Overview
After compaction (v0.15.0) or in long conversations, the model loses access
to earlier material — attachment content injected once at send time, and
message details compressed into summaries. These tools let the model
re-read source material on demand.
Depends on: file handling (v0.12.0), tool framework (v0.11.0), compaction (v0.15.0).
**Four work streams:**
1. `attachment_recall` — re-read attachment content from storage
2. `conversation_search` — keyword search over channel messages
3. Token estimator improvements — attachment-aware context counting
4. KB auto-injection — automatic knowledge base context in system prompt
---
## 1. `attachment_recall` Tool
### Purpose
Attachments are injected into context once (at the user message that
references them). On subsequent turns the model relies on its own response
as a natural summary. After compaction, that summary is gone. This tool
lets the model re-read any attachment in the current channel.
### Two-action design
Single tool, `action` parameter selects behavior:
- **`list`** — returns metadata for all attachments in the channel
(id, filename, content_type, size, created_at). The model calls this
first to discover what's available.
- **`read`** — returns content for a specific attachment_id.
Documents → `extracted_text` from DB. Images → base64 data URI.
### Security
Channel-scoped: `attachment_id` must belong to `ExecutionContext.ChannelID`.
The store query is `GetByChannel(channelID)` so cross-channel access is
impossible by construction.
### Dependencies
Late registration (same pattern as `kb_search`):
```go
func RegisterAttachmentRecall(stores store.Stores, objStore storage.ObjectStore)
```
Called from `main.go` after storage init. If `objStore` is nil (storage
not configured), the tool is **not registered** — it simply won't appear
in the tool list.
### Implementation: `server/tools/attachment_recall.go`
```go
type attachmentRecallTool struct {
stores store.Stores
objStore storage.ObjectStore
}
func (t *attachmentRecallTool) Definition() ToolDef {
return ToolDef{
Name: "attachment_recall",
DisplayName: "Attachments",
Category: "context",
Description: "Re-read file attachments from this conversation. " +
"Use action='list' to see available files, then action='read' " +
"with an attachment_id to get the content.",
Parameters: JSONSchema(map[string]interface{}{
"action": PropEnum("Action to perform", "list", "read"),
"attachment_id": Prop("string", "Attachment ID (required for read)"),
}, []string{"action"}),
}
}
```
**`list` action returns:**
```json
{
"attachments": [
{
"id": "abc-123",
"filename": "report.pdf",
"content_type": "application/pdf",
"size_bytes": 45200,
"has_text": true,
"created_at": "2026-02-26T10:00:00Z"
}
],
"count": 1
}
```
**`read` action returns:**
```json
{
"id": "abc-123",
"filename": "report.pdf",
"content_type": "application/pdf",
"content": "... extracted text ...",
"content_type_returned": "text"
}
```
For images:
```json
{
"id": "def-456",
"filename": "diagram.png",
"content_type": "image/png",
"content": "data:image/png;base64,...",
"content_type_returned": "base64_image"
}
```
Image base64 is capped at 10MB to prevent context blow-up. Larger images
return an error suggesting the user re-upload.
---
## 2. `conversation_search` Tool
### Purpose
After compaction, message details are compressed into summaries. If the
user asks about something the summary omitted, the model needs to search
the original messages. Also useful in long pre-compaction conversations
where the model's attention has drifted.
### Design
Keyword search (full-text) over messages in the current channel using
PostgreSQL's `plainto_tsquery`. No migration needed — `to_tsvector` is
computed on the fly. This is acceptable because:
- Search is scoped to a single channel (bounded message count)
- Called infrequently (tool invocation, not every request)
- Channel messages rarely exceed ~10K rows
If performance becomes an issue, a `search_vector tsvector` column with
GIN index can be added in a future migration.
### Parameters
```go
Parameters: JSONSchema(map[string]interface{}{
"query": Prop("string", "Search keywords"),
"max_results": map[string]interface{}{
"type": "integer",
"description": "Maximum results (1-20, default 5)",
},
"role_filter": PropEnum("Filter by message role", "all", "user", "assistant"),
}, []string{"query"})
```
### Returns
```json
{
"results": [
{
"message_id": "...",
"role": "user",
"excerpt": "...highlighted excerpt...",
"timestamp": "2026-02-26T10:00:00Z",
"rank": 0.95
}
],
"query": "original query",
"total": 3,
"channel_id": "..."
}
```
Uses `ts_headline` for highlighted excerpts (same pattern as `note_search`).
### Security
Channel-scoped via `ExecutionContext.ChannelID`. Only searches messages
in the current channel. Soft-deleted messages (`deleted_at IS NOT NULL`)
are excluded.
### Dependencies
Late registration:
```go
func RegisterConversationSearch(stores store.Stores)
```
No external dependencies beyond stores (uses `database.DB` directly for
the full-text query, same pattern as `note_search`).
---
## 3. Token Estimator Improvements
### Frontend (`tokens.js`)
Current `estimateConversation` counts message text only. Enhancements:
- **Staged attachments**: Before send, show estimated tokens for queued
attachments. Documents: `extracted_text.length / 4`. Images: flat
~765 tokens per image (OpenAI's base tile estimate, reasonable cross-provider).
- **Sent attachments**: After send, the augmented content is already in
the message text (documents are inlined). Images were ephemeral
ContentParts — add their estimate to the message's token count.
### Implementation
Add to `Tokens`:
```javascript
estimateAttachments(attachments) {
let total = 0;
for (const att of attachments) {
if (att.content_type?.startsWith('image/')) {
total += 765; // base tile estimate
} else if (att.extracted_text) {
total += this.estimate(att.extracted_text);
} else {
total += Math.ceil(att.size_bytes / 4); // rough fallback
}
}
return total;
}
```
Wire into `updateInputTokens()` to include staged attachment estimates.
---
## 4. KB Auto-Injection
### Purpose
Currently the model must explicitly call `kb_search` to access knowledge
base content. For common patterns (channel linked to a KB, user asks a
question), automatic injection into the system prompt is more natural.
### Design
In the completion handler, after loading conversation history and before
building the provider request:
1. Check if channel has linked KBs (or user has personal KBs)
2. Extract the user's latest message as the query
3. Run similarity search (top-K, K=3 default)
4. If results found AND context budget allows, prepend to system prompt
### Context budget check
```go
contextUsed := estimateTokens(systemPrompt + allMessages)
kbTokens := estimateTokens(kbContent)
if contextUsed + kbTokens > maxContext * 0.85 {
// Skip injection — context too full
log.Printf("⏭ KB auto-inject skipped: context %.0f%% full", pct*100)
}
```
### Channel settings
```json
{
"kb_injection": "auto" | "tool_only" | "both" | "off"
}
```
Default: `"auto"` — inject when KBs are linked. `"tool_only"` disables
auto-injection but keeps the `kb_search` tool available. `"both"` does
both. `"off"` disables all KB context.
### System prompt format
```
[Knowledge Base Context]
The following information was retrieved from the user's knowledge bases
and may be relevant to this conversation:
---
Source: Technical Docs (chunk 3 of report.pdf, similarity: 0.87)
Content: ...
---
Source: FAQ (chunk 1 of faq.md, similarity: 0.82)
Content: ...
[End Knowledge Base Context]
```
---
## File Inventory
### New files
| File | Description |
|------|-------------|
| `server/tools/attachment_recall.go` | attachment_recall tool |
| `server/tools/conversation_search.go` | conversation_search tool |
| `docs/DESIGN-0.15.1.md` | This design doc |
### Modified files
| File | Change |
|------|--------|
| `server/main.go` | Register new tools |
| `server/handlers/completion.go` | KB auto-injection in completion flow |
| `src/js/tokens.js` | Attachment-aware token estimation |
| `docs/ROADMAP.md` | Mark v0.15.1 items |
### No migration required
- `conversation_search` uses on-the-fly `to_tsvector` (no new column)
- `attachment_recall` reads existing `extracted_text` column
- KB auto-injection uses existing `kb_search` infrastructure
---
## Implementation Order
1. **Phase 1**: `attachment_recall` tool + registration ✅
2. **Phase 2**: `conversation_search` tool + registration ✅
3. **Phase 3**: Token estimator improvements (frontend) ✅
4. **Phase 4**: KB auto-injection (completion handler) — separate delivery,
requires adding embedder dependency to `CompletionHandler` and careful
latency budgeting (embedding query on every completion call)

File diff suppressed because it is too large Load Diff

View File

@@ -1,235 +0,0 @@
# DESIGN-0.18.0: Memory System
**Version:** 0.18.0
**Status:** Phase 1 Complete
**Depends on:** compaction (v0.15.0), knowledge bases (v0.14.0), persona-KB binding (v0.17.0), SQLite backend (v0.17.1)
---
## 1. Overview
Memory provides long-term fact persistence across conversations. Unlike
compaction (within-conversation context) or knowledge bases (static
documents), memory captures preferences, facts, and context that
accumulate over time from natural conversation.
**Key differentiator: Persona-scoped memory.** Each Persona builds
its own memory independently, preventing cross-context contamination.
A helpdesk Persona accumulates FAQ knowledge; a tutoring Persona
tracks per-student progress; a user's personal preferences stay
separate from any Persona context.
---
## 2. Scope Model
Three memory scopes with clear ownership:
```
┌─────────────────────────────────────────────────┐
│ user scope │
│ owner_id = user_id │
│ "Jeff prefers Go over Python" │
│ "Deployment uses Kubernetes + Traefik" │
│ Visible in ALL conversations for this user │
└─────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────┐
│ persona scope │
│ owner_id = persona_id │
│ "Common question: how to reset password" │
│ "Policy: refunds within 30 days only" │
│ Shared across ALL users of this Persona │
└─────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────┐
│ persona_user scope │
│ owner_id = persona_id, user_id = user_id │
│ "Student struggles with recursion" │
│ "Customer prefers email over phone" │
│ Per-user within a specific Persona │
└─────────────────────────────────────────────────┘
```
**Scope resolution at recall time:**
When a Persona is active, all three scopes are queried and merged
with priority ordering: persona_user > persona > user. When no
Persona is active, only user scope is queried.
---
## 3. Data Model
### 3.1 Table: `memories`
| Column | Type | Description |
|--------|------|-------------|
| `id` | UUID / TEXT | Primary key |
| `scope` | TEXT | `user`, `persona`, `persona_user` |
| `owner_id` | UUID / TEXT | user_id (user scope) or persona_id (persona/persona_user) |
| `user_id` | UUID / TEXT | Only for persona_user scope — identifies which user |
| `key` | TEXT | Short label (2-6 words) |
| `value` | TEXT | The fact/detail |
| `source_channel_id` | UUID / TEXT | Provenance — which conversation |
| `confidence` | REAL | 0.0-1.0, LLM-provided |
| `status` | TEXT | active, pending_review, archived |
| `embedding` | vector(3072) / TEXT | For semantic recall (Phase 2) |
| `created_at` | TIMESTAMPTZ / TEXT | |
| `updated_at` | TIMESTAMPTZ / TEXT | |
### 3.2 Indexes
- **Primary lookup:** `(scope, owner_id, status)` — filtered to `status = 'active'`
- **Persona+user:** `(owner_id, user_id)` — filtered to `scope = 'persona_user'`
- **Dedup:** unique on `(scope, owner_id, COALESCE(user_id, nil_uuid), key)`
- **FTS:** GIN on `to_tsvector(key || ' ' || value)` (Postgres) / LIKE fallback (SQLite)
- **Provenance:** `(source_channel_id)` — "where did this memory come from?"
### 3.3 Model Struct
```go
type Memory struct {
ID string `json:"id"`
Scope string `json:"scope"`
OwnerID string `json:"owner_id"`
UserID *string `json:"user_id,omitempty"`
Key string `json:"key"`
Value string `json:"value"`
SourceChannelID *string `json:"source_channel_id,omitempty"`
Confidence float64 `json:"confidence"`
Status string `json:"status"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
```
---
## 4. Store Interface
```go
type MemoryStore interface {
Upsert(ctx, *Memory) error // create or update (matched on scope+owner+user+key)
GetByID(ctx, id) (*Memory, error)
List(ctx, MemoryFilter) ([]Memory, error)
Delete(ctx, id) error // hard delete
Archive(ctx, id) error // soft delete (status → archived)
Recall(ctx, userID, *personaID, query, limit) ([]Memory, error) // scope-merged search
CountByOwner(ctx, scope, ownerID) (int, error)
}
```
**Key design decisions:**
- `Upsert` uses the unique index for conflict resolution — same key = update value
- `Recall` does a UNION across all applicable scopes with priority ordering
- Both Postgres and SQLite implementations exist (same pattern as all other stores)
- SQLite falls back to LIKE for text search (no tsvector)
---
## 5. Tools
### 5.1 `memory_save`
The LLM calls this to explicitly store a fact. Scope is automatically
determined by whether a Persona is active:
- No Persona → `user` scope, `owner_id = user_id`
- Persona active → `persona_user` scope, `owner_id = persona_id`, `user_id = user_id`
Parameters: `key` (required), `value` (required), `confidence` (optional, default 1.0)
### 5.2 `memory_recall`
The LLM calls this to search stored memories. Results merge across
all applicable scopes.
Parameters: `query` (optional), `max_results` (optional, default 20)
### 5.3 Registration
Late registration pattern (same as notes, KB search, conversation search):
```go
// main.go
tools.RegisterMemoryTools(stores)
```
---
## 6. Memory Injection
At completion time, `loadConversation()` injects relevant memories
into the system prompt — same position as KB hints and compaction
summaries:
```
[admin system prompt]
[persona/channel system prompt]
[KB hint]
[memory injection] ← NEW
[compaction summary OR message history]
```
`BuildMemoryHint()` calls `Recall()` with an empty query (returns
top memories by confidence/recency), formats as bullet points, and
caps at ~1500 tokens (~6000 chars) to preserve context budget.
---
## 7. Implementation Phases
### Phase 1: Foundation & Tools ✅ (this delivery)
- Migrations (Postgres + SQLite)
- Memory model struct
- MemoryStore interface + both implementations
- memory_save + memory_recall tools
- Memory injection in loadConversation
- 9 new files, ~5 edits to existing files
### Phase 2: Automatic Extraction & Embeddings
- Background job: post-conversation analysis extracts facts
- Configurable extraction prompt per Persona
- Extracted memories start as `pending_review`
- Embedding generation for semantic recall
- Recall upgraded to use vector similarity + keyword hybrid
### Phase 3: Review Pipeline & User Controls
- Admin review queue for pending memories
- Persona memory review for team admins
- User Settings → Memory: view, edit, delete
- Per-Persona memory toggle
- Retention policies
- Frontend memory management UI
---
## 8. Persona Memory Saves (Future: Phase 2)
Phase 1 only supports `user` and `persona_user` scopes via the tool
(the LLM saves about the user, scoped appropriately). Phase 2 adds
the ability for admins/team leads to configure Persona-scope memory
extraction — the Persona "learns" from conversations:
```
Persona system prompt addition (Phase 2):
"After each conversation, identify key facts, common questions,
and useful patterns. Save these as persona-scope memories for
future reference by all users."
```
This runs as a background job, not during the live conversation,
to avoid latency impact.
---
## 9. Privacy & Security
- User memories never cross team boundaries
- Persona memories respect group access controls
- persona_user memories are only visible to the specific user + admins
- Users can view/delete their own memories (Phase 3 UI)
- Admin can disable memory system globally or per-team
- No cross-persona memory leakage — scope isolation is enforced at the query level

View File

@@ -1,244 +0,0 @@
# Design — v0.18.1 Side Panel Architecture
## Problem
The side panel is a single `<aside>` with two mutually exclusive tabs
(Preview and Notes) toggled by `display: none`. Opening Notes kills the
preview and vice versa. As more panel content arrives (KB browser,
search results, memory viewer), the single-slot model doesn't scale.
The current implementation also loses per-panel state (scroll position,
active note, preview content) on tab switches.
This is also prerequisite plumbing for v0.23.0 multi-participant
channels, where multiple panels need simultaneous visibility.
## Current State
**HTML:** Single `<aside id="sidePanel">` containing:
- Resize handle (`sidePanelResize`)
- Header with tab bar (Preview | Notes) + action buttons (clear, fullscreen, close)
- Body with two `.side-panel-page` divs toggled by display
**JS (ui-format.js lines 295365):**
- `openSidePanel(tab)` — adds `.open` class, calls `switchSidePanelTab`
- `closeSidePanel()` — removes `.open` and `.fullscreen`, clears inline width
- `switchSidePanelTab(tab)` — toggles active class on tab buttons, toggles display on page divs
- `_initSidePanelResize()` — mousedown/move/up drag handler for width resize
**Callers (4 total):**
1. `toggleHTMLPreview()``openSidePanel('preview')` (ui-format.js)
2. `openNotes()``openSidePanel('notes')` / `closeSidePanel()` (notes.js)
3. Escape handler → `closeSidePanel()` (app.js)
4. Inline onclick handlers in index.html (tab buttons, close, fullscreen, clear)
**CSS (styles.css lines 642737):**
- `.side-panel` base: `width: 0; min-width: 0; overflow: hidden`, transition on width
- `.side-panel.open`: `width: 480px; min-width: 480px`
- `.side-panel.fullscreen`: fixed positioning, 100% width
- Mobile (`@media max-width: 768px`): open = fixed overlay at 100vw
## Design
### Panel Registry
A lightweight registry in a new `panels.js` file. Each panel is a named
entry with its own state and DOM container. The registry owns all
open/close/focus logic — individual panels never touch the `<aside>`
directly.
```js
const PanelRegistry = {
_panels: {}, // { name: { element, onOpen, onClose, onFocus, state } }
_active: null, // currently visible panel name (single-view mode)
_secondary: null, // second visible panel (dual-view mode)
_dualMode: false,
register(name, opts) { ... },
open(name) { ... },
close(name) { ... },
toggle(name) { ... },
closeAll() { ... },
isOpen(name) { ... },
active() { ... },
};
```
**Panel registration contract:**
```js
PanelRegistry.register('notes', {
element: document.getElementById('sidePanelNotes'),
onOpen() { loadNotesList(); loadNoteFolders(); },
onClose() { /* optional cleanup */ },
onFocus() { /* optional re-activate */ },
state: {}, // panel-specific state bag (scroll pos, etc.)
});
```
Panels register themselves during initialization — Notes registers in
`notes.js`, Preview registers in `ui-format.js`. This keeps domain
logic co-located with its panel.
### DOM Structure
Replace the hardcoded tab bar with a registry-driven header. The
`<aside>` structure stays identical in shape (no layout break), but
tabs render dynamically from registered panels:
```html
<aside class="side-panel" id="sidePanel">
<div class="side-panel-resize" id="sidePanelResize"></div>
<div class="side-panel-header">
<div class="side-panel-tabs" id="sidePanelTabs">
<!-- rendered by PanelRegistry from registered panels -->
</div>
<div class="side-panel-actions" id="sidePanelActions">
<!-- per-panel action buttons injected by active panel -->
<button class="side-panel-action-btn" id="sidePanelFullscreenBtn" ...>
<button class="side-panel-close" ...>
</div>
</div>
<div class="side-panel-body" id="sidePanelBody">
<!-- all .side-panel-page divs, shown/hidden by registry -->
</div>
</aside>
```
The tab bar is re-rendered on `register()` and on `open()`/`close()`.
Each panel can declare `actions` — an array of button configs that
appear in the header when that panel is active (replacing the current
hardcoded clear button for preview).
### State Preservation
Each panel gets a `state` bag on its registry entry. Before hiding a
panel, the registry calls a `saveState()` hook (if provided) which can
snapshot scroll position, selection, etc. On re-show, `restoreState()`
re-applies. This is opt-in — panels that don't need it don't implement
the hooks.
Notes state: `{ scrollTop, editingNoteId, viewMode: 'list'|'editor'|'graph' }`
Preview state: `{ srcdoc, hasContent }`
### Dual-View Mode (Phase 2)
When enabled, the panel body splits into two panes with an internal
divider. `_active` shows on the left (or top on mobile), `_secondary`
on the right (or bottom). The outer resize handle still controls total
panel width; an inner divider controls the split ratio.
Dual mode activates via:
- `Ctrl/Cmd+Shift+\` keyboard shortcut
- Dragging a tab to the other half (stretch goal)
### Per-Panel Action Buttons
Currently Preview has a "Clear" button and both panels share Fullscreen
and Close. The new model:
- **Global actions** (always visible): Fullscreen, Close
- **Panel actions** (per-panel, in active panel's header slot):
- Preview: Clear
- Notes: (none currently, but room for "New Note" shortcut)
- Future panels declare their own
Panels declare actions at registration time:
```js
PanelRegistry.register('preview', {
element: document.getElementById('sidePanelPreview'),
actions: [
{ id: 'sidePanelClearBtn', icon: '...svg...', title: 'Clear preview',
onClick: clearPreview, visible: () => hasPreviewContent() },
],
});
```
### Preview Enhancements (Phase 3)
- **Live-update during streaming:** The chat streaming handler already
accumulates HTML code blocks. On each chunk that completes a fenced
code block tagged `html`, if the preview panel is open, update the
iframe's `srcdoc`. Debounce at 500ms to avoid thrashing.
- **Extension block preview:** Mermaid, KaTeX, etc. render inline in
chat. Add a "Pop out" button on extension-rendered blocks that opens
the rendered output in the preview panel (full-size, zoomable).
### Mobile (Phase 4)
- **Swipe navigation:** Touch event handlers on `.side-panel-body`
swipe left/right cycles through open panels. Uses CSS `transform:
translateX()` with a 150px threshold for commit.
- **Bottom sheet mode:** On viewports < 768px, the secondary panel in
dual mode renders as a bottom sheet (40% height) instead of side-by-
side.
- **Priority collapse:** When space is tight and dual mode can't fit
(< 600px combined), collapse to single-panel with most-recently-
opened winning. The displaced panel becomes accessible via tab bar.
### Keyboard Shortcuts
| Shortcut | Action |
|----------|--------|
| `Ctrl/Cmd+\` | Cycle active panel (or open panel container if closed) |
| `Ctrl/Cmd+Shift+\` | Toggle dual-view mode |
| `Escape` | Close panel container (existing behavior, unchanged) |
## Phases
### Phase 1 — Panel Registry + State Management
Extract panel logic from `ui-format.js` into `panels.js`. Implement
`PanelRegistry` with register/open/close/toggle/closeAll. Migrate
Notes and Preview to register as panels. Dynamic tab bar rendering.
Per-panel action buttons. State preservation hooks.
**Files changed:**
- New: `src/js/panels.js`
- Modified: `src/js/ui-format.js` (remove panel functions, add preview registration)
- Modified: `src/js/notes.js` (replace `openSidePanel`/`closeSidePanel` calls with `PanelRegistry.open`/`close`)
- Modified: `src/js/app.js` (update Escape handler, add keyboard shortcuts, replace `_initSidePanelResize` call)
- Modified: `src/index.html` (remove hardcoded tab buttons, add `panels.js` script tag, make tab bar dynamic)
- No CSS changes (reuse existing `.side-panel-tab` styles)
**Backward compatibility:** All existing panel behavior works identically.
`openSidePanel()` and `closeSidePanel()` become thin wrappers around
`PanelRegistry` during transition, then get removed after all callers
migrate.
### Phase 2 — Dual-View Layout
CSS grid split inside `.side-panel-body`. Internal divider with drag
resize. Keyboard shortcut for dual toggle. Tab drag to split (stretch).
**Files changed:**
- Modified: `src/js/panels.js` (dual-view logic)
- Modified: `src/css/styles.css` (grid layout for dual panes)
### Phase 3 — Preview Enhancements
Live-update preview during streaming. Extension block "pop out" button.
**Files changed:**
- Modified: `src/js/ui-format.js` (pop-out button on extension blocks)
- Modified: `src/js/chat.js` (streaming hook for live preview update)
- Modified: `src/js/panels.js` (debounced preview update API)
### Phase 4 — Mobile
Swipe navigation, bottom sheet mode, priority collapse.
**Files changed:**
- Modified: `src/js/panels.js` (touch handlers, priority logic)
- Modified: `src/css/styles.css` (bottom sheet layout, responsive breakpoints)
## Migration Checklist
- [ ] `openSidePanel(tab)``PanelRegistry.open(tab)`
- [ ] `closeSidePanel()``PanelRegistry.closeAll()`
- [ ] `switchSidePanelTab(tab)``PanelRegistry.open(tab)` (open implies focus)
- [ ] `toggleSidePanelFullscreen()` → stays global (operates on container, not individual panels)
- [ ] `_initSidePanelResize()` → moves to `panels.js` (operates on container)
- [ ] Escape handler: `PanelRegistry.closeAll()`
- [ ] `openNotes()` toggle logic: `PanelRegistry.toggle('notes')`
- [ ] `toggleHTMLPreview()`: `PanelRegistry.open('preview')` + update iframe content

File diff suppressed because it is too large Load Diff

View File

@@ -1,194 +0,0 @@
# v0.19.1 — Active Projects, Project System Prompt, Project Detail Panel
**Status:** Implementation
**Depends on:** v0.19.0 Projects/Workspaces
---
## Overview
Three tightly-scoped improvements that make projects genuinely usable
for daily workflow rather than just organizational containers:
1. **Active Project** — one-click project activation so new chats
auto-assign without drag-drop.
2. **Project System Prompt** — per-project instructions injected into
every chat within the project at completion time.
3. **Project Detail Panel** — side panel UI for managing a project's
system prompt, KB bindings, and notes.
---
## 1. Active Project
### State
| Location | Key | Type |
|----------|-----|------|
| `App.activeProjectId` | runtime | `string \| null` |
| `localStorage` | `cs-active-project` | `string \| null` |
### Behavior
- Click project header's **pin icon** (or "Set as active" in ⋯ menu)
to activate.
- Active project gets a visual indicator: left accent border + subtle
background tint using the project's color.
- Creating a new chat (via `sendMessage` when `!App.currentChatId`)
auto-calls `addChannelToProject(activeProjectId, newChannelId)` and
sets `chat.projectId` client-side.
- Only one project can be active at a time. Clicking the pin on an
already-active project deactivates it.
- Active state persists across page reloads via `localStorage`.
- If the active project is deleted, `activeProjectId` is cleared.
### Files Changed
| File | Change |
|------|--------|
| `src/js/app.js` | Add `activeProjectId: null` to App, restore from localStorage on init |
| `src/js/projects-ui.js` | `setActiveProject(id)`, `clearActiveProject()`, menu items, pin icon |
| `src/js/chat.js` | Auto-assign in `sendMessage` after channel creation |
| `src/js/ui-core.js` | `.active` class on project group in `renderChatList` |
| `src/css/styles.css` | `.project-group.active` styling |
---
## 2. Project System Prompt
### Injection Order
```
1. Admin system prompt (global policy — no opt-out)
2. Persona OR channel prompt (role/character definition)
3. ▸ PROJECT system prompt (project-specific context) ← NEW
4. KB hint (available knowledge bases)
5. Memory hint (user facts from v0.18.0)
6. Summary / conversation (message history)
```
The project prompt sits between the role definition and the KB hint.
This lets a persona define "you are a coding assistant" while the
project adds "this project is the React dashboard for client X."
### Backend Flow (completion.go)
In `loadConversation`, after the persona/channel system prompt block:
```go
// ── Project system prompt (v0.19.1) ──
if h.stores.Projects != nil {
projID, _ := h.stores.Projects.GetProjectIDForChannel(ctx, channelID)
if projID != "" {
proj, err := h.stores.Projects.GetByID(ctx, projID)
if err == nil {
if sp, ok := proj.Settings["system_prompt"].(string); ok && sp != "" {
messages = append(messages, providers.Message{
Role: "system",
Content: sp,
})
}
}
}
}
```
### Storage
Uses existing `projects.settings` JSONB column. No migration needed.
```json
{
"system_prompt": "You are helping build the Chat Switchboard project..."
}
```
### Files Changed
| File | Change |
|------|--------|
| `server/handlers/completion.go` | Inject project system prompt in `loadConversation` |
---
## 3. Project Detail Panel
### Registration
Follows the same `PanelRegistry.register()` pattern as notes and
preview panels. Registered during app init via `_registerProjectPanel()`.
### Panel Layout
```
┌─────────────────────────────┐
│ Project: <name> [✕] │ ← header (editable name)
├─────────────────────────────┤
│ System Prompt │
│ ┌─────────────────────────┐ │
│ │ textarea (auto-resize) │ │
│ └─────────────────────────┘ │
│ [Save] │
├─────────────────────────────┤
│ Knowledge Bases [+] │
│ ┌─────────────────────────┐ │
│ │ • KB Alpha [✕] │ │
│ │ • KB Beta [✕] │ │
│ └─────────────────────────┘ │
│ (empty: "No KBs bound") │
├─────────────────────────────┤
│ Notes │
│ ┌─────────────────────────┐ │
│ │ • Meeting notes [✕] │ │
│ │ • Design spec [✕] │ │
│ └─────────────────────────┘ │
│ (empty: "No notes") │
├─────────────────────────────┤
│ Color [●] │
│ Archived [ ] │
└─────────────────────────────┘
```
### Interactions
- **System prompt:** Textarea with debounced save (1s) or explicit Save
button. Writes to `PUT /projects/:id` with
`settings: { system_prompt: "..." }`.
- **KB add:** [+] opens a dropdown of available KBs (from
`GET /knowledge-bases`), filtered to exclude already-bound ones.
Calls `POST /projects/:id/knowledge-bases`.
- **KB remove:** [✕] calls `DELETE /projects/:id/knowledge-bases/:kbId`.
- **Notes:** Read-only list with [✕] to unbind. Notes are auto-associated
from channel creation (v0.19.0).
- **Color:** Reuses the invisible color picker pattern from
`setProjectColor()`.
- **Open trigger:** Click project name in sidebar header, or
"Project details" in ⋯ menu.
### Files Changed
| File | Change |
|------|--------|
| `src/index.html` | Add `#sidePanelProject` div inside `#sidePanelBody` |
| `src/js/projects-ui.js` | `openProjectPanel(id)`, `_registerProjectPanel()`, panel rendering, KB/note management |
| `src/js/app.js` | Call `_registerProjectPanel()` in init |
| `src/css/styles.css` | `.project-panel-*` styles |
---
## Files Summary
| File | Phase | Type |
|------|-------|------|
| `server/handlers/completion.go` | 2 | Modified |
| `src/js/app.js` | 1,3 | Modified |
| `src/js/projects-ui.js` | 1,3 | Modified |
| `src/js/chat.js` | 1 | Modified |
| `src/js/ui-core.js` | 1 | Modified |
| `src/css/styles.css` | 1,3 | Modified |
| `src/index.html` | 3 | Modified |
| `VERSION` | — | Modified (0.19.0 → 0.19.1) |
| `CHANGELOG.md` | — | Modified |
No new files. No migrations. Backend change is a single injection
point in completion.go.

View File

@@ -1,116 +0,0 @@
# v0.19.2 — Project Persona Default, Archive Toggle, Channel Reorder
**Status:** Implementation
**Depends on:** v0.19.1
---
## 1. Project-level Persona Default
### Concept
Bind a persona to a project so all chats inherit model, system prompt,
and parameters without selecting it each time. The persona acts as a
fallback when no explicit preset is chosen per-message.
### Resolution Chain (completion.go)
```
1. Explicit req.PresetID → highest priority
2. Project persona default → fallback when no explicit preset ← NEW
3. No persona → bare model, no persona context
```
### Storage
`projects.settings` JSONB:
```json
{ "persona_id": "uuid-here", "system_prompt": "..." }
```
### Backend Changes
In `completion.go`, after the explicit preset block, before
`resolveConfig`:
```go
// ── Project persona fallback (v0.19.2) ──
if req.PresetID == "" && h.stores.Projects != nil {
projID, _ := h.stores.Projects.GetProjectIDForChannel(ctx, channelID)
if projID != "" {
proj, _ := h.stores.Projects.GetByID(ctx, projID)
if proj != nil {
if pid, ok := proj.Settings["persona_id"].(string); ok && pid != "" {
preset := ResolvePreset(h.stores, pid, userID)
if preset != nil {
personaID = preset.ID
if req.Model == "" { req.Model = preset.BaseModelID }
// ... same defaults as explicit preset
}
}
}
}
}
```
### Frontend Changes
Project detail panel: persona picker dropdown below system prompt.
Lists available personas, saves to `settings.persona_id`.
---
## 2. Project Archive Toggle
### Behavior
- Toggle in project detail panel (checkbox)
- Archived projects hidden from sidebar by default
- "Show archived" toggle at bottom of sidebar (or in panel)
- Uses existing `is_archived` column — no migration needed
### Files Changed
- `src/js/projects-ui.js` — archive toggle in panel, sidebar filter
- `src/js/ui-core.js` — filter archived in renderChatList
- `src/css/styles.css` — dimmed style for archived
---
## 3. Channel Reorder Within Project
### Behavior
- Dragging a chat within the same project group reorders it
- Drop between chats inserts at that position
- Calls `PUT /projects/:id/channels/reorder` with new order
- Position persisted server-side, respected on next load
### Implementation
- `onProjectDrop` detects same-project reorder vs cross-project move
- Compute new channel order from DOM after drop
- Channels within project sorted by position from
`project_channels.position` (loaded alongside projects)
### Files Changed
- `src/js/projects-ui.js` — reorder logic
- `src/js/ui-core.js` — sort projChats by position
- `src/js/app.js` — store project channel positions
---
## Files Summary
| File | Change |
|------|--------|
| `server/handlers/completion.go` | Project persona fallback |
| `src/js/projects-ui.js` | Persona picker, archive toggle, reorder |
| `src/js/ui-core.js` | Archive filter, position sort |
| `src/js/app.js` | Channel positions state |
| `src/css/styles.css` | Archive styles |
| `VERSION` | 0.19.1 → 0.19.2 |
| `CHANGELOG.md` | New entry |
No migrations. No new files.

View File

@@ -1,7 +1,7 @@
# ICD-API — Chat Switchboard Backend API Contract
**Version:** 0.22.7
**Updated:** 2026-03-03
**Version:** 0.23.0
**Updated:** 2026-03-04
**Audience:** Frontend developers, integrators, API consumers
> **Organization principle:** Each section is one domain — the complete story.
@@ -31,6 +31,7 @@
15. [Teams & Access Control](#15-teams--access-control)
16. [Platform Administration](#16-platform-administration)
17. [Export & Utility](#17-export--utility)
17b. [Files & Storage](#17b-files--storage)
18. [WebSocket Protocol](#18-websocket-protocol)
19. [Appendix: Enums](#19-appendix-enums)
@@ -118,14 +119,6 @@ both Postgres and SQLite backends.
ISO 8601 with timezone: `"2025-06-15T14:30:00Z"`. Stored as
`TIMESTAMPTZ` (Postgres) or `TEXT` (SQLite).
### Naming Note
The domain concept is **Persona** (identity + system prompt + model +
KB bindings + grant scoping). API routes currently use `/personas` for
historical reasons. A pre-1.0 rename to `/personas` is planned. This
document uses "Persona" for the concept and shows the current route
paths as-is.
---
## 2. Auth
@@ -209,8 +202,9 @@ Revokes the refresh token. Returns `{ "message": "logged out" }`.
The **channel** is the universal conversation container. Messages,
tool activity, attachments, and KB bindings all hang off a channel.
Today channels are single-user direct chats; the architecture
anticipates multi-participant channels for v0.23.0.
Channels support multiple participants — users, personas, and
sessions — making them the foundation for collaborative and
multi-model workflows.
### 3.1 Channel CRUD
@@ -225,9 +219,8 @@ Returns pagination envelope. Each channel:
```json
{
"id": "uuid",
"user_id": "uuid",
"title": "My Chat",
"type": "direct",
"type": "direct|group|workflow",
"description": "",
"model": "claude-sonnet-4-20250514",
"provider_config_id": "uuid|null",
@@ -235,11 +228,20 @@ Returns pagination envelope. Each channel:
"project_id": "uuid|null",
"folder": "string|null",
"tags": ["tag1", "tag2"],
"participant_count": 1,
"created_at": "...",
"updated_at": "..."
}
```
**Channel types:**
| Type | Description |
|------|-------------|
| `direct` | Single-user chat (default, backward compatible) |
| `group` | Multi-user/multi-model collaborative channel |
| `workflow` | Staged channel driven by workflow definitions |
**Create channel:**
```
@@ -259,9 +261,10 @@ POST /channels
}
```
On create, if `model` and `provider_config_id` are provided, the
channel model roster (`channel_models`) is auto-populated with an
initial entry.
On create, the authenticated user is automatically added as a
participant with `role: "owner"`. If `model` and `provider_config_id`
are provided, the channel model roster (`channel_models`) is
auto-populated with an initial entry.
**Get channel:**
@@ -315,6 +318,8 @@ following the cursor at each fork. This is what the UI renders.
"content": "...",
"model": "claude-sonnet-4-20250514|null",
"provider_config_id": "uuid|null",
"participant_id": "uuid|null",
"participant_type": "user|persona|session|null",
"thinking": "...|null",
"tool_calls": [...],
"tool_results": [...],
@@ -328,6 +333,11 @@ following the cursor at each fork. This is what the UI renders.
}
```
`participant_id` and `participant_type` identify who authored the
message. For `user` messages, this is the authenticated user. For
`assistant` messages, this is the persona (if set) or null for raw
model responses. Existing messages without participants return null.
**List all messages** — includes all branches, flat:
```
@@ -432,15 +442,23 @@ POST /chat/completions
}
```
`channel_id` is required. `chat_id` is accepted as a deprecated alias
(maps to `channel_id`; frontend no longer sends it — removal planned).
`channel_id` is required.
`extra_body` is a freeform JSON object merged into the provider request
(provider-specific parameters like `temperature`, `reasoning`, etc.).
**Persona resolution:** If `persona_id` is set, the handler loads the
persona's system prompt, model, provider_config_id, and KB bindings.
Per-message `model`/`provider_config_id` override the persona's defaults.
**Participant scoping:** The requesting user must be a participant in
the channel. Messages are attributed to the authenticated user (or
session). In `group` channels, all participants see all messages in
real time via WebSocket.
**Persona resolution:** The primary completion path. If `persona_id`
is set (explicitly or via `@mention` resolution from message content),
the handler loads the persona's system prompt, model, provider config,
and KB bindings. Per-message `model`/`provider_config_id` override the
persona's defaults. In multi-persona channels, `@mention` in the
message content is parsed to resolve the target persona from the
channel's participant list.
**Routing resolution:** After config resolution, the routing policy
evaluator (`evaluateRouting()`) may redirect to a different provider
@@ -502,10 +520,16 @@ GET /tools
Returns `{ "tools": [...] }` where each tool has `name`, `description`,
`parameters` (JSON Schema), and `category`.
### 3.4 Multi-Model Roster
### 3.4 Multi-Model Roster (Raw Access)
Channels can have multiple AI models. The `channel_models` table tracks
the roster. `@mention` in message content routes to specific models.
The primary way to add AI to a channel is by adding a **persona as a
participant** (§3.7). Adding a persona automatically populates the
channel model roster with the persona's underlying model.
For the rare case where a user needs raw model access without a
persona wrapper, the `channel_models` table provides direct model
roster management. This is the 1% escape hatch — most users should
never need it.
```
GET /channels/:id/models → { "models": [...] }
@@ -524,10 +548,20 @@ Each roster entry:
"provider_config_id": "uuid",
"display_name": "Claude",
"is_default": true,
"persona_id": "uuid|null",
"created_at": "..."
}
```
`persona_id`: set when this roster entry was auto-created by adding a
persona participant. Null for manually-added raw models.
**`@mention` resolution:** When a user types `@` in a channel, the
autocomplete shows persona participants first (by display name), then
raw model roster entries. The target of a `@mention` is always
resolved to a specific `provider_config_id` + `model` pair — never
an ambiguous bare model name.
### 3.5 Channel Utilities
**Summarize channel** (compaction):
@@ -548,65 +582,236 @@ POST /channels/:id/generate-title
Uses the utility model to auto-generate a title from the first
exchange. Returns `{ "title": "Generated Title" }`.
### 3.6 Attachments
### 3.6 Files
File attachments are channel-scoped. Upload stores the file in blob
storage; a background extraction pipeline processes documents (PDF,
DOCX, XLSX, etc.) into searchable text.
All binary content associated with channels — user uploads, tool-
generated artifacts, system files — lives in the unified `files`
table backed by the ObjectStore (see §X). The old `attachments`
table and routes are removed.
**Upload:**
**Upload (user):**
```
POST /channels/:id/attachments
POST /channels/:id/files
Content-Type: multipart/form-data
```
Field: `file`. Returns the attachment object.
Field: `file`. Sets `origin: "user_upload"`. Returns the file object.
**Create (tool output):**
```
POST /files
```
```json
{
"channel_id": "uuid",
"message_id": "uuid",
"filename": "generated_image.png",
"content_type": "image/png",
"display_hint": "inline",
"metadata": { "tool_name": "image_generation" }
}
```
Body: multipart `file` field or base64 `data` field.
Sets `origin: "tool_output"`. Returns file object.
**List by channel:**
```
GET /channels/:id/attachments
GET /channels/:id/files?origin=user_upload
```
Returns `{ "attachments": [...] }`.
Returns `{ "files": [...] }`. Filterable by `origin`, `content_type`.
**List by message:**
```
GET /messages/:id/files → { "files": [...] }
```
How the frontend discovers generated artifacts to render inline.
**List by user (file manager):**
```
GET /files?page=1&per_page=50 → { "files": [...], "total": N }
```
All files owned by the authenticated user, paginated.
**Get metadata:**
```
GET /attachments/:id
GET /files/:id
```
```json
{
"id": "uuid",
"channel_id": "uuid",
"message_id": "uuid|null",
"user_id": "uuid",
"origin": "user_upload|tool_output|system",
"filename": "report.pdf",
"content_type": "application/pdf",
"size": 1048576,
"storage_key": "attachments/chan-id/att-id_report.pdf",
"extracted_text_key": "...|null",
"size_bytes": 1048576,
"display_hint": "inline|download|thumbnail",
"extracted_text": "...|null",
"metadata": {},
"created_at": "..."
"created_at": "...",
"updated_at": "..."
}
```
**Download:**
```
GET /attachments/:id/download
GET /files/:id/download
```
Returns the file with appropriate `Content-Type` and
`Content-Disposition` headers.
**Thumbnail:**
```
GET /files/:id/thumbnail
```
**Delete:**
```
DELETE /attachments/:id
DELETE /files/:id
```
Deletes metadata + blob. Channel deletion cascades via FK +
`DeletePrefix`.
### 3.7 Channel Participants
The participant system is how users and AI interact in a channel.
**Personas are the primary way to add AI** — adding a persona as a
participant brings its full identity (name, avatar, system prompt,
model, provider config, KB bindings) into the channel. This is the
99% path. Raw model access without a persona is available via the
model roster (§3.4) for power users.
Every channel has at least one participant (the owner). `direct`
channels have exactly one user participant and optionally one or more
persona participants. `group` and `workflow` channels support multiple
participants of mixed types.
**List participants:**
```
GET /channels/:id/participants
```
Returns `{ "participants": [...] }`:
```json
{
"id": "uuid",
"channel_id": "uuid",
"participant_type": "user|persona|session",
"participant_id": "uuid",
"role": "owner|member|observer",
"display_name": "Jane Doe",
"avatar_url": "...|null",
"joined_at": "..."
}
```
**Participant types:**
| Type | Description |
|------|-------------|
| `user` | Authenticated user. `participant_id` = `users.id` |
| `persona` | AI persona added to channel. `participant_id` = `personas.id`. Brings model, system prompt, KB bindings |
| `session` | Anonymous/session participant (workflow intake). `participant_id` = session token |
**Participant roles:**
| Role | Capabilities |
|------|-------------|
| `owner` | Full control: add/remove participants, delete channel, all member capabilities |
| `member` | Send messages, trigger completions, view history |
| `observer` | Read-only: view messages, no send |
**Add participant:**
```
POST /channels/:id/participants
```
```json
{
"participant_type": "user|persona",
"participant_id": "uuid",
"role": "member"
}
```
Requires `owner` role in the channel. Adding a `persona` participant:
- Makes the persona available as an `@mention` target
- Adds the persona's model to the channel model roster automatically
- Scopes the persona's KB bindings into the channel's KB resolution chain
- The persona's avatar and display name appear in the participant list
**Update participant role:**
```
PATCH /channels/:id/participants/:participantId
```
```json
{ "role": "observer" }
```
**Remove participant:**
```
DELETE /channels/:id/participants/:participantId
```
Cannot remove the last owner. Removing a `persona` participant also
removes its auto-created model roster entry.
**Completion routing:** When `@PersonaName` appears in message
content, the completion handler resolves to that persona's model,
provider config, and system prompt. When no `@mention` is present in
a multi-persona channel, the channel's default persona (first added)
handles the response. The `persona_id` field in the completion
request (§3.3) can also be set explicitly to override `@mention`
resolution.
**Backward compatibility:** Existing `direct` channels that predate
the participant system are auto-migrated on first access — a single
`user` participant with `role: "owner"` is created from the channel's
legacy `user_id` column.
### 3.8 Presence
Real-time presence for channel participants. Delivered via WebSocket
to all subscribers of the channel room.
**Who's here:**
```
GET /channels/:id/presence
```
Returns `{ "participants": [{ "participant_id", "display_name", "status", "last_seen" }] }`.
`status`: `online`, `idle`, `offline`.
**Typing indicators** are sent via WebSocket (see §18.4). The typing
event payload includes `participant_id` and `participant_type` so the
UI can distinguish between human and AI typing.
---
## 4. Personas
@@ -616,6 +821,16 @@ prompt + model + provider config + KB bindings + grant scoping. Users
interact with Personas, not raw provider configs. Admins curate which
Personas are available; team admins create team-scoped Personas.
**Model inheritance:** A Persona inherits all properties of its
underlying model. This includes context window size, max output
tokens, supported capabilities (thinking, tools, vision, streaming),
input/output pricing, and provider status. When the frontend displays
a Persona, it resolves the underlying model's capabilities and shows
the same pills/badges (e.g. thinking, tools, 200k context) that the
raw model would show. The Persona adds identity (name, avatar, system
prompt, KB bindings) on top of the model's capabilities — it never
masks or reduces them.
Three scopes, three route namespaces, one domain:
| Scope | Routes | Who manages |
@@ -637,31 +852,46 @@ All three return the same Persona object shape:
"scope": "personal|team|global",
"owner_id": "uuid|null",
"is_default": false,
"is_active": true,
"avatar_url": "/api/v1/personas/uuid/avatar?v=123",
"inherited": {
"context_window": 200000,
"max_output_tokens": 8192,
"supports_vision": true,
"supports_tools": true,
"supports_thinking": true,
"supports_streaming": true,
"input_price_per_m": 3.00,
"output_price_per_m": 15.00,
"provider_status": "healthy|degraded|down|null"
},
"created_at": "...",
"updated_at": "..."
}
```
The `inherited` block is populated by the backend from the resolved
model capabilities at response time — it is not stored on the persona.
If the underlying model's capabilities change (e.g. provider upgrades
context window), the persona inherits the new values automatically.
The frontend uses `inherited` to render capability pills and context
size badges identical to the raw model.
### 4.1 Personal Personas
Gated by `allow_user_personas` policy.
```
GET /personas → { "personas": [...], "personas": [...] }
GET /personas → { "personas": [...] }
POST /personas ← { "name", "description", "system_prompt", "model", ... }
PUT /personas/:id ← partial update
DELETE /personas/:id
```
**Note:** List response currently sends both `"personas"` and `"personas"`
keys (identical arrays) for backward compatibility. The `"personas"` key
is what the frontend reads. Dual keys will be collapsed pre-1.0.
### 4.2 Team Personas
```
GET /teams/:teamId/personas → { "personas": [...], "personas": [...] }
GET /teams/:teamId/personas → { "personas": [...] }
POST /teams/:teamId/personas ← same shape
PUT /teams/:teamId/personas/:id
DELETE /teams/:teamId/personas/:id
@@ -670,7 +900,7 @@ DELETE /teams/:teamId/personas/:id
### 4.3 Admin (Global) Personas
```
GET /admin/personas → { "personas": [...], "personas": [...] }
GET /admin/personas → { "personas": [...] }
POST /admin/personas ← same shape
PUT /admin/personas/:id
DELETE /admin/personas/:id
@@ -1512,9 +1742,6 @@ The primary endpoint for populating model selectors:
GET /models/enabled
```
> **Note:** `GET /models` exists as an alias. The alias is unused by
> the frontend and will be removed pre-1.0.
Returns `{ "models": [UserModel objects] }`:
```json
@@ -1536,23 +1763,59 @@ Returns `{ "models": [UserModel objects] }`:
"output_price_per_m": 15.00,
"provider_status": "healthy|degraded|down|null",
"scope": "global|team|personal",
"source": "catalog|heuristic"
"source": "catalog|heuristic",
"is_persona": false,
"persona_id": "uuid|null",
"persona_scope": "global|team|personal|null",
"persona_avatar": "url|null",
"persona_team_name": "string|null"
}
```
The capabilities on each model are resolved through the three-tier
chain: catalog DB → heuristic inference → admin overrides (see §10.5).
When `is_persona` is `true`, the capability fields (`context_window`,
`max_output_tokens`, `supports_*`, pricing, `provider_status`) are
inherited from the persona's underlying model. The frontend renders
the same capability pills and badges for a persona as for its raw
model — the persona adds identity, not capability restrictions.
### 11.2 User Model Preferences
Users can hide models they don't want to see.
Users can hide models they don't want to see and set per-model
defaults. Preferences are keyed on the **composite identity**
`provider_config_id:model_id` — the same model from different
providers can have independent visibility.
```
GET /models/preferences → { "preferences": { "model-id": { "hidden": true } } }
PUT /models/preferences ← { "model_id": "...", "hidden": true }
POST /models/preferences/bulk ← { "preferences": { "id1": { "hidden": true }, "id2": { ... } } }
GET /models/preferences → { "preferences": [PreferenceEntry objects] }
PUT /models/preferences ← { "model_id": "...", "provider_config_id": "uuid", "hidden": true }
POST /models/preferences/bulk ← { "entries": [{ "model_id": "...", "provider_config_id": "uuid", "hidden": true }] }
```
PreferenceEntry:
```json
{
"model_id": "grok-4.1-fast",
"provider_config_id": "uuid",
"hidden": true,
"preferred_temperature": 0.7,
"preferred_max_tokens": 4096,
"sort_order": 0
}
```
**Identity rule:** `provider_config_id` is required on write. The same
bare `model_id` from two different provider configs (e.g. global Venice
vs personal BYOK Venice) are independent preference entries. The
frontend composite ID format is `{provider_config_id}:{model_id}`.
**Persona preferences:** Personas are not in this table. A persona's
visibility is controlled by the grant system (§15.3) and the
`is_active` flag, not by model preferences.
---
## 12. Notifications
@@ -2022,6 +2285,121 @@ Docker images).
---
## 17b. Files & Storage
All binary content in Chat Switchboard flows through a single
**ObjectStore** abstraction backed by PVC (filesystem) or S3. All
file metadata lives in one `files` table. The old `attachments`
table is dropped.
### 17b.1 Storage Backend
| Operation | Description |
|-----------|-------------|
| `Put(key, reader, size, contentType)` | Store a blob |
| `Get(key)` → reader, size, contentType | Retrieve a blob |
| `Delete(key)` | Remove a blob |
| `DeletePrefix(prefix)` | Bulk remove (channel deletion) |
| `Exists(key)` | Check without reading |
| `Healthy()` | Backend health check |
| `Stats()` | Aggregate metrics |
Backends: `pvc` (`STORAGE_PATH=/data/storage`) or `s3`
(`S3_ENDPOINT`, `S3_BUCKET`, `S3_ACCESS_KEY`, `S3_SECRET_KEY`).
### 17b.2 Key Namespacing
| Prefix | Pattern | What |
|--------|---------|------|
| `attachments/` | `attachments/{channel_id}/{file_id}_{filename}` | User uploads + AI outputs (legacy prefix retained for existing blobs) |
| `kb/` | `kb/{kb_id}/{doc_id}_{filename}` | Knowledge base documents |
| `exports/` | `exports/{user_id}/{export_id}_{filename}` | Chat/data exports |
| `workspace/` | `workspace/{workspace_id}/{path}` | Workspace files |
### 17b.3 File Object
```sql
CREATE TABLE files (
id UUID PRIMARY KEY,
channel_id UUID REFERENCES channels(id) ON DELETE CASCADE,
message_id UUID REFERENCES messages(id) ON DELETE SET NULL,
user_id UUID REFERENCES users(id) ON DELETE SET NULL,
project_id UUID REFERENCES projects(id) ON DELETE SET NULL,
origin TEXT NOT NULL DEFAULT 'user_upload'
CHECK (origin IN ('user_upload','tool_output','system')),
filename TEXT NOT NULL,
content_type TEXT NOT NULL DEFAULT 'application/octet-stream',
size_bytes BIGINT NOT NULL DEFAULT 0,
storage_key TEXT NOT NULL,
display_hint TEXT NOT NULL DEFAULT 'download'
CHECK (display_hint IN ('inline','download','thumbnail')),
extracted_text TEXT,
metadata JSONB DEFAULT '{}',
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
```
**Origin:**
| Value | Who creates | Examples |
|-------|-------------|---------|
| `user_upload` | User via upload UI | PDF, image, doc attached to message |
| `tool_output` | Tool execution during completion | DALL-E image, video gen, code artifact |
| `system` | Platform | Export, avatar stored as file |
**Display hint:**
| Value | Frontend behavior |
|-------|-------------------|
| `inline` | Render in message stream (images, video, audio, HTML) |
| `download` | Filename + size + download button |
| `thumbnail` | Thumbnail preview, click to expand |
### 17b.4 Tool Output Flow
1. Tool executes (image gen, video render, etc.)
2. Tool writes blob via `ObjectStore.Put`
3. Tool creates file record: `POST /files` with `origin: "tool_output"`, `message_id` = current assistant message
4. Tool result includes `file_id` reference
5. WebSocket emits `file.created` event:
```json
{
"event": "file.created",
"channel_id": "uuid",
"message_id": "uuid",
"file": { ...file object... }
}
```
6. Frontend renders inline based on `display_hint` + `content_type`
The event fires during streaming so the user sees the artifact
before the completion finishes.
### 17b.5 Content Type → Display Hint Defaults
| Content Type | Default Hint | Rendering |
|-------------|-------------|-----------|
| `image/*` | `inline` | `<img>` in message, lightbox on click |
| `video/*` | `inline` | `<video>` player |
| `audio/*` | `inline` | `<audio>` player |
| `text/html` | `inline` | Sandboxed iframe |
| `application/pdf` | `thumbnail` | Thumb + PDF viewer on click |
| `text/*`, `application/json` | `inline` | Syntax-highlighted code block |
| Everything else | `download` | Filename + download button |
Tools can override the default hint explicitly.
### 17b.6 File Manager (Future Surface)
A user-scoped file browser backed by `GET /files?page=1&per_page=50`.
Displays all files owned by the user across all channels, grouped by
channel or date. Supports download and delete. Deleting a file that
is referenced by a message replaces the inline render with a
"file deleted" placeholder.
## 18. WebSocket Protocol
Real-time bidirectional event bus. Single connection per client.
@@ -2069,8 +2447,12 @@ Clients subscribe to rooms for scoped event delivery:
| `message.updated` | → client | Message content changed |
| `message.deleted` | → client | Message removed |
| `cursor.updated` | → client | Branch cursor moved |
| `typing.start` | ↔ both | User/AI started typing |
| `typing.start` | ↔ both | Participant started typing (payload includes `participant_id`, `participant_type`) |
| `typing.stop` | ↔ both | Typing ended |
| `participant.joined` | → client | Participant added to channel |
| `participant.left` | → client | Participant removed from channel |
| `participant.updated` | → client | Participant role changed |
| `presence.update` | → client | Participant online/idle/offline status change |
| `model.changed` | → client | Channel model roster changed |
| `persona.updated` | → client | Persona metadata changed |
| `kb.updated` | → client | Knowledge base changed |
@@ -2088,6 +2470,8 @@ Clients subscribe to rooms for scoped event delivery:
| `extension.updated` | → client | Extension config changed |
| `settings.updated` | → client | Global settings changed |
| `user.updated` | → client | User profile changed |
| `file.created` | → client | File uploaded or generated (§17b.4) |
| `file.deleted` | → client | File removed |
Direction: `→ client` = server-to-client only, `← client` = client-to-server
only, `↔ both` = bidirectional.
@@ -2098,7 +2482,19 @@ only, `↔ both` = bidirectional.
### Channel Types
`direct` (current default), `group` (v0.23.0), `workflow` (v0.25.0)
`direct` (single-user), `group` (multi-user/multi-model), `workflow` (staged)
### Participant Types
`user`, `persona`, `session`
### Participant Roles
`owner`, `member`, `observer`
### Presence Statuses
`online`, `idle`, `offline`
### Message Roles

View File

@@ -1,8 +1,8 @@
# ICD Audit: API Contract vs ROADMAP / ARCHITECTURE
**Date:** 2026-03-03 | **Baseline:** v0.22.7
**Date:** 2026-03-04 | **Baseline:** v0.23.0
**Scope:** Cross-reference ICD-API.md against ROADMAP.md, ARCHITECTURE.md, and live codebase.
**Status:** Items §2 (naming), §3 (cleanup) resolved in v0.22.8 naming cleanup pass. See ICD-API.md (domain-organized v2) and DESIGN-SURFACES.md.
**Status:** §2 (naming) fully resolved — preset→persona rename complete across all JS, templates, CSS, Go handlers, and ICD. §3 (cleanup) resolved. §4.1 (chat_id alias) resolved. §4.2 (legacy list messages) open. ICD updated to target state including multi-participant channels.
---