docs: ROADMAP full restore + #98-143 tech debt links + archive historical DESIGN/CHANGES (#144)

This commit is contained in:
2026-03-02 13:14:20 +00:00
parent 14c691b52f
commit 9940fb5831
20 changed files with 2422 additions and 4570 deletions

View File

@@ -0,0 +1,99 @@
# v0.18.0 Phase 1 — Changes Guide
## New Files (drop in place)
| File | Description |
|------|-------------|
| `server/database/migrations/004_v0180_memories.sql` | Postgres migration — memories table, indexes, trigger |
| `server/database/migrations/sqlite/003_v0180_memories.sql` | SQLite equivalent |
| `server/models/models_memory.go` | Memory + MemoryFilter model structs + scope/status constants |
| `server/store/store_memory.go` | MemoryStore interface definition |
| `server/store/postgres/memory.go` | Postgres MemoryStore implementation |
| `server/store/sqlite/memory.go` | SQLite MemoryStore implementation |
| `server/tools/memory.go` | memory_save + memory_recall tools with late registration |
| `server/handlers/memory_inject.go` | BuildMemoryHint() for context injection |
| `docs/DESIGN-0.18.0.md` | Design document |
## Existing File Modifications
### 1. `server/store/interfaces.go`
Add to the `Stores` struct (after `ResourceGrants`):
```go
Memories MemoryStore
```
The `MemoryStore` interface itself is in the new `store_memory.go` file
(same package, separate file for cleanliness).
### 2. `server/store/postgres/stores.go`
Add to the `NewStores()` return:
```go
Memories: NewMemoryStore(),
```
### 3. `server/store/sqlite/stores.go`
Add to the `NewStores()` return:
```go
Memories: NewMemoryStore(),
```
### 4. `server/main.go`
Add after the existing tool registrations (~line 169):
```go
// Memory tools (v0.18.0) — late registration, needs stores
tools.RegisterMemoryTools(stores)
```
### 5. `server/handlers/completion.go`
In `loadConversation()`, add memory injection after the KB hint block
(~line 798, after the `BuildKBHint` block):
```go
// ── Memory injection (recall known facts about user) ──
if memHint := BuildMemoryHint(context.Background(), h.stores, userID, personaID); memHint != \"\" {
messages = append(messages, providers.Message{
Role: \"system\",
Content: memHint,
})
}
```
### 6. `VERSION`
```
0.18.0
```
### 7. `docs/ROADMAP.md`
Mark Phase 1 items as complete (Data Model, Tools, Memory Injection basics).
---
## Testing Checklist
1. **Migration runs clean** — both Postgres and SQLite
2. **Tools register** — check startup logs for `🔧 Registered tool: memory_save` and `memory_recall`
3. **memory_save** — in a chat, tell the AI something personal (\"I prefer Go over Python\"). The AI should call memory_save.
4. **memory_recall** — in a NEW chat, ask \"what programming language do I prefer?\" The AI should call memory_recall and find it.
5. **Persona scoping** — with a Persona active, memories save as `persona_user` scope. Without a Persona, they save as `user` scope.
6. **Upsert** — saving the same key twice updates the value instead of creating a duplicate.
7. **Injection** — memories appear in the system prompt context (check server logs for `🧠 Injected N memories`).
## Architecture Notes
- Memory injection happens in `loadConversation()` as a system message, same pattern as KB hints and compaction summaries
- Scope priority in Recall: persona_user > persona > user (most specific wins)
- The unique index on `(scope, owner_id, COALESCE(user_id, nil_uuid), key)` prevents duplicate keys within a scope
- Confidence is LLM-provided: 1.0 for explicit statements, lower for inferences
- Status field supports Phase 2's review pipeline (pending_review, archived)
- Embedding column exists but is unused in Phase 1 — Phase 2 adds semantic recall

View File

@@ -0,0 +1,191 @@
# v0.18.0 Phase 2 — Changes Guide
## Overview
Phase 2 adds: automatic memory extraction via background scanner,
embedding-powered semantic recall, memory review pipeline API, and
persona memory configuration.
## New Files (drop in place)
| File | Description |
|------|-------------|
| `server/database/migrations/005_v0180_memory_phase2.sql` | Postgres: persona memory columns, HNSW index, extraction log table |
| `server/database/migrations/sqlite/004_v0180_memory_phase2.sql` | SQLite equivalent |
| `server/memory/extractor.go` | Extraction service — calls utility model to extract facts from conversations |
| `server/memory/scanner.go` | Background scanner — finds conversations to extract, same pattern as compaction.Scanner |
| `server/store/postgres/memory_hybrid.go` | RecallHybrid — pgvector cosine distance + keyword merge |
| `server/store/sqlite/memory_hybrid.go` | RecallHybrid — app-level cosine similarity + keyword merge |
| `server/handlers/memory.go` | REST API: list, edit, delete, approve/reject memories |
| `server/handlers/memory_inject.go` | **REPLACES Phase 1 version** — now supports hybrid recall with embeddings |
## Updated Files (replace Phase 1 versions)
| File | What Changed |
|------|-------------|
| `server/tools/memory.go` | **REPLACES Phase 1 version** — RegisterMemoryTools now takes embedder param, memory_save embeds on save |
## Existing File Modifications
### 1. `server/models/models.go` — Persona struct
Add after the `KBIDs` field:
```go
MemoryEnabled bool `json:\"memory_enabled\" db:\"memory_enabled\"`
MemoryExtractionPrompt *string `json:\"memory_extraction_prompt,omitempty\" db:\"memory_extraction_prompt\"`
```
Add to `PersonaPatch`:
```go
MemoryEnabled *bool `json:\"memory_enabled,omitempty\"`
MemoryExtractionPrompt *string `json:\"memory_extraction_prompt,omitempty\"`
```
### 2. `server/store/store_memory.go` — MemoryStore interface
Add to the interface:
```go
// RecallHybrid returns active memories using vector similarity + keyword search.
// If queryVec is nil/empty, falls back to keyword-only (same as Recall).
RecallHybrid(ctx context.Context, userID string, personaID *string, query string, queryVec []float64, limit int) ([]models.Memory, error)
```
### 3. `server/main.go` — Tool registration + scanner startup
**Update** the existing RegisterMemoryTools call (~line 170) to pass the embedder:
```go
// Memory tools (v0.18.0) — late registration, needs stores + embedder
tools.RegisterMemoryTools(stores, kbEmbedder)
```
**Add** memory extraction scanner startup (after compaction scanner, or near the end
of the startup block — see compaction scanner as pattern reference):
```go
// Memory extraction scanner (v0.18.0 Phase 2)
// Opt-in: requires global_settings.memory_extraction_enabled = true
memExtractor := memory.NewExtractor(stores, roleResolver, kbEmbedder)
memScanner := memory.NewScanner(memExtractor, stores, memory.ScannerConfig{})
memScanner.Start()
defer memScanner.Stop()
```
Import: `\"git.gobha.me/xcaliber/chat-switchboard/memory\"`
### 4. `server/main.go` — Memory API routes
Add under the protected routes (~after presets/personas routes):
```go
// Memory management (v0.18.0)
memH := handlers.NewMemoryHandler(stores)
protected.GET(\"/memories\", memH.ListMyMemories)
protected.PUT(\"/memories/:id\", memH.UpdateMemory)
protected.DELETE(\"/memories/:id\", memH.DeleteMemory)
protected.POST(\"/memories/:id/approve\", memH.ApproveMemory)
protected.POST(\"/memories/:id/reject\", memH.RejectMemory)
protected.GET(\"/memories/count\", memH.MemoryCount)
```
Add under admin routes:
```go
// Admin memory review (v0.18.0)
adminMemH := handlers.NewMemoryHandler(stores)
admin.GET(\"/memories/pending\", adminMemH.ListPendingReview)
admin.POST(\"/memories/bulk-approve\", adminMemH.BulkApprove)
```
### 5. `server/handlers/completion.go` — BuildMemoryHint signature change
The `BuildMemoryHint` call in `loadConversation()` changes signature.
Update from Phase 1:
```go
// OLD (Phase 1):
if memHint := BuildMemoryHint(context.Background(), h.stores, userID, personaID); memHint != \"\" {
// NEW (Phase 2) — pass embedder and last user message for semantic recall:
lastUserMsg := \"\"
for i := len(messages) - 1; i >= 0; i-- {
if messages[i].Role == \"user\" {
lastUserMsg = messages[i].Content
break
}
}
if memHint := BuildMemoryHint(context.Background(), h.stores, h.embedder, userID, personaID, lastUserMsg); memHint != \"\" {
```
This requires adding the embedder to CompletionHandler. In the struct:
```go
type CompletionHandler struct {
vault *crypto.KeyResolver
stores store.Stores
hub *events.Hub
objStore storage.ObjectStore
embedder *knowledge.Embedder // NEW: for memory semantic recall
}
```
And in `NewCompletionHandler`:
```go
func NewCompletionHandler(vault *crypto.KeyResolver, stores store.Stores, hub *events.Hub, objStore storage.ObjectStore, embedder *knowledge.Embedder) *CompletionHandler {
return &CompletionHandler{vault: vault, stores: stores, hub: hub, objStore: objStore, embedder: embedder}
}
```
Update the call site in main.go:
```go
comp := handlers.NewCompletionHandler(keyResolver, stores, hub, objStore, kbEmbedder)
```
### 6. `server/store/postgres/persona.go` — Scan memory fields
Any Persona scan queries need to include the new columns. Add
`memory_enabled` and `memory_extraction_prompt` to SELECT lists and Scan calls
in `GetByID`, `ListForUser`, etc.
### 7. `server/store/sqlite/persona.go` — Same as above for SQLite.
---
## Admin Setup
The extraction scanner is **opt-in**. To enable:
1. Set global config: `memory_extraction_enabled` = `true`
2. The scanner runs every 10 minutes (configurable)
3. Extracted memories start as `pending_review`
4. Admin reviews via `GET /api/v1/admin/memories/pending`
5. Approve: `POST /api/v1/memories/:id/approve`
6. Reject: `POST /api/v1/memories/:id/reject`
## Testing Checklist
1. **Migration** — both Postgres and SQLite, verify `memory_extraction_log` table created
2. **Persona columns**`ALTER TABLE personas ADD COLUMN memory_enabled` runs clean
3. **HNSW index** — Postgres only, verify with `\\di+ idx_memories_embedding`
4. **Embedding on save** — call memory_save, check logs for `🧠 memory X embedded`
5. **Hybrid recall** — with embeddings populated, memory_recall should find semantically relevant results even with different keywords
6. **Extraction scanner** — set `memory_extraction_enabled=true` in global config, wait for scan cycle, verify `🧠 memory extraction:` logs
7. **Extracted status** — auto-extracted memories should have `status=pending_review`
8. **Review API**`GET /memories?status=pending_review` returns pending items
9. **Approve/reject** — POST approve changes status to active, reject archives
10. **BuildMemoryHint** — with embedder, verify semantic recall contextualizes based on user's message
## Architecture Notes
- **Extraction scanner** follows compaction.Scanner pattern: ticker loop, semaphore concurrency, in-flight dedup, graceful shutdown via Stop()
- **Extraction log** (`memory_extraction_log`) prevents re-processing: tracks last_message_id per channel+user
- **RecallHybrid** merges vector + keyword results with dedup by ID; semantic results rank first
- **SQLite hybrid** loads all embedded memories into Go and computes cosine similarity in-process (acceptable for single-user deployments)
- **Persona memory toggle** (`memory_enabled`) gates both tool-based and extraction-based memory for that persona
- **Extraction prompt** is customizable per persona — a helpdesk persona extracts FAQ patterns, a tutoring persona extracts learning progress
- **Phase 3** will add the frontend UI: Settings → Memory panel, admin review queue, per-persona toggle in persona editor

View File

@@ -0,0 +1,282 @@
# v0.18.0 Phase 3 — Frontend UI Changes Guide
## Overview
Phase 3 adds the user-facing memory management UI:
- **Settings → Memory tab**: view, search, edit, approve/reject memories
- **Admin → AI → Memory**: review pipeline for auto-extracted memories
- **Persona forms**: memory_enabled toggle + custom extraction prompt
- **Admin → System → Settings**: memory extraction toggle
## New Files (drop in place)
| File | Lines | Description |
|------|-------|-------------|
| `src/js/memory-ui.js` | ~310 | Memory UI module — settings panel, admin panel, persona form fields |
| `src/css/memory.css` | ~230 | Styles for memory cards, badges, toolbar, edit forms |
## Existing File Modifications
### 1. `src/index.html` — Add Memory Tab + Admin Section
#### A. Settings Modal — Add Memory Tab Button
After the Knowledge tab button (~line 389):
```html
<button class=\"settings-tab\" data-stab=\"memory\" onclick=\"UI.switchSettingsTab('memory')\" id=\"settingsMemoryTabBtn\">Memory</button>
```
#### B. Settings Modal — Add Memory Tab Content
After the Knowledge Bases tab content block (after `settingsKnowledgeBasesTab` div, ~line 552):
```html
<!-- Memory Tab (v0.18.0) -->
<div class=\"settings-tab-content\" id=\"settingsMemoryTab\" style=\"display:none\">
<section class=\"settings-section\">
<h3 style=\"font-size:14px;margin-bottom:4px\">My Memories</h3>
<p class=\"section-hint\" style=\"margin-bottom:8px\">Facts and preferences learned from your conversations. Memories help AI provide more personalized responses.</p>
<div id=\"settingsMemoryContent\"></div>
</section>
</div>
```
#### C. Admin Panel — Add Memory Section
In the admin sections HTML, after the `adminPresetsTab` block (~line 790),
add a new admin section:
```html
<div class=\"admin-section-content\" id=\"adminMemoryTab\" style=\"display:none\">
<div id=\"adminMemoryContent\"></div>
</div>
```
#### D. Admin Settings — Add Memory Extraction Toggle
In `adminSettingsTab`, after the Auto-Compaction section (~line 908):
```html
<section class=\"settings-section\">
<h3>Memory Extraction</h3>
<label class=\"checkbox-label\"><input type=\"checkbox\" id=\"adminMemoryExtractionEnabled\"> Enable automatic memory extraction</label>
<p class=\"section-hint\">When enabled, the system automatically extracts memorable facts from conversations using the utility model. Extracted memories require admin approval before becoming active. Requires a utility model role.</p>
<div id=\"memoryExtractionConfigFields\" style=\"display:none;margin-top:8px\">
<label class=\"checkbox-label\"><input type=\"checkbox\" id=\"adminMemoryAutoApprove\"> Auto-approve extracted memories</label>
<p class=\"section-hint\">Skip the review pipeline and activate extracted memories immediately. Not recommended for sensitive environments.</p>
</div>
</section>
```
#### E. Script + CSS Include
In the `<head>` section, add the CSS:
```html
<link rel=\"stylesheet\" href=\"css/memory.css\">
```
In the script block at the bottom (after `knowledge-ui.js`):
```html
<script src=\"js/memory-ui.js\"></script>
```
---
### 2. `src/js/api.js` — Memory API Client Methods
Add after the user presets section (~line 310):
```javascript
// Memory (v0.18.0)
listMemories(status, query) {
const params = new URLSearchParams();
if (status) params.set('status', status);
if (query) params.set('query', query);
return this._get('/api/v1/memories?' + params.toString());
},
getMemoryCount() { return this._get('/api/v1/memories/count'); },
updateMemory(id, data) { return this._put(`/api/v1/memories/${id}`, data); },
deleteMemory(id) { return this._del(`/api/v1/memories/${id}`); },
approveMemory(id) { return this._post(`/api/v1/memories/${id}/approve`); },
rejectMemory(id) { return this._post(`/api/v1/memories/${id}/reject`); },
bulkApproveMemories(ids) { return this._post('/api/v1/admin/memories/bulk-approve', { ids }); },
adminListPendingMemories() { return this._get('/api/v1/admin/memories/pending'); },
```
---
### 3. `src/js/ui-core.js` — Wire Memory Tab into Settings
In `switchSettingsTab()` (~line 932), add after the `knowledgeBases` case:
```javascript
if (tab === 'memory') {
if (typeof MemoryUI !== 'undefined') {
MemoryUI.openSettingsPanel();
}
}
```
---
### 4. `src/js/ui-admin.js` — Register Memory in Admin Panel
#### A. Add \"memory\" to ADMIN_SECTIONS (~line 9)
```javascript
const ADMIN_SECTIONS = {
people: ['users', 'teams', 'groups'],
ai: ['providers', 'models', 'presets', 'roles', 'knowledgeBases', 'memory'],
system: ['settings', 'storage', 'extensions'],
monitoring: ['usage', 'audit', 'stats'],
};
```
#### B. Add label (~line 18)
In ADMIN_LABELS, add:
```javascript
memory: 'Memory',
```
#### C. Add loader (~line 37)
In ADMIN_LOADERS, add:
```javascript
memory: () => {
if (typeof MemoryUI === 'undefined') {
console.error('[Admin] MemoryUI not loaded');
return;
}
MemoryUI.openAdminPanel();
},
```
---
### 5. `src/js/ui-admin.js` — Load/Save Memory Extraction Settings
#### A. In `loadAdminSettings()` (~after compaction config loading)
Add after the compaction threshold/cooldown block:
```javascript
// Memory Extraction (v0.18.0)
const memCfg = getSetting('memory_extraction', {}) || {};
const memExtractionEl = document.getElementById('adminMemoryExtractionEnabled');
if (memExtractionEl) {
memExtractionEl.checked = !!memCfg.enabled;
document.getElementById('memoryExtractionConfigFields').style.display =
memCfg.enabled ? '' : 'none';
}
const memAutoApproveEl = document.getElementById('adminMemoryAutoApprove');
if (memAutoApproveEl) memAutoApproveEl.checked = !!memCfg.auto_approve;
```
#### B. Wire toggle visibility
Add in `_initAdminListeners()` or inline:
```javascript
document.getElementById('adminMemoryExtractionEnabled')?.addEventListener('change', function() {
document.getElementById('memoryExtractionConfigFields').style.display =
this.checked ? '' : 'none';
});
```
---
### 6. `src/js/settings-handlers.js` — Save Memory Extraction Config
In `handleSaveAdminSettings()`, before the final toast (~line 252):
```javascript
// Memory Extraction config (v0.18.0)
const memExtractionEnabled = document.getElementById('adminMemoryExtractionEnabled')?.checked || false;
const memAutoApprove = document.getElementById('adminMemoryAutoApprove')?.checked || false;
await API.adminUpdateSetting('memory_extraction', { value: {
enabled: memExtractionEnabled,
auto_approve: memAutoApprove,
}});
await API.adminUpdateSetting('memory_extraction_enabled', { value: memExtractionEnabled });
```
---
### 7. `src/js/ui-core.js` — Persona Form Memory Fields
For **admin** persona forms, in the function that initializes the admin preset
form (after `renderPresetForm` call), add:
```javascript
if (typeof MemoryUI !== 'undefined') {
MemoryUI.appendMemoryFields(container, prefix);
}
```
And in the submit handler, merge memory values:
```javascript
const values = form.getValues();
if (typeof MemoryUI !== 'undefined') {
Object.assign(values, MemoryUI.getMemoryValues(prefix));
}
```
When editing an existing persona, call:
```javascript
if (typeof MemoryUI !== 'undefined') {
MemoryUI.setMemoryValues(prefix, persona);
}
```
---
## Testing Checklist
1. **Settings → Memory tab** — click tab, verify memory list loads
2. **Memory search** — type in search box, verify filtering works
3. **Memory status filter** — switch between Active/Pending/Archived
4. **Edit memory** — click pencil icon, modify key/value, save
5. **Delete memory** — click trash icon, confirm, verify removal
6. **Approve/Reject** — with pending memories, verify buttons work
7. **Approve All** — bulk approve pending memories
8. **Admin → AI → Memory** — navigate to admin memory panel
9. **Admin pending review** — verify pending memories show with approve/reject
10. **Admin bulk approve** — verify bulk action works
11. **Admin Settings → Memory Extraction** — toggle on/off, verify checkbox persists
12. **Persona form** — create new persona, verify memory section appears
13. **Persona memory toggle** — uncheck, verify it persists on save
14. **Mobile responsive** — test all memory views on narrow viewport
15. **Empty states** — verify graceful display when no memories exist
## CSS Variable Dependencies
The memory styles reference these existing CSS variables:
- `--bg-1`, `--bg-2`, `--bg-3` — background layers
- `--text-1`, `--text-2`, `--text-3` — text colors
- `--border` — border color
- `--accent`, `--accent-dim`, `--accent-text` — accent colors
- `--success`, `--warning`, `--danger` — status colors
All are defined in the existing `styles.css` theme system.
## Architecture Notes
- `MemoryUI` follows the same pattern as `KnowledgeUI` — standalone module
that integrates via `typeof MemoryUI !== 'undefined'` guards
- No build step required — vanilla JS module loaded via `<script>` tag
- Admin memory section registered in `ADMIN_SECTIONS` and `ADMIN_LOADERS`
maps following the existing convention
- Persona memory fields are appended dynamically via `appendMemoryFields()`
rather than embedded in `renderPresetForm()` to avoid modifying the shared
form builder
- Debounce on search input prevents excessive API calls
- Memory cards use inline edit — no modal, same pattern as note inline editing
- CSS uses existing theme variables for consistent dark/light mode support

View File

@@ -0,0 +1,149 @@
# v0.12.0 — File Handling + Vision (Design)
## Overview
File input into chat — table stakes for serious use. Image uploads for
vision-capable models, document uploads for text extraction into context,
and the storage backend that v0.14.0 (Knowledge Bases) and v0.15.0
(Compaction snapshots) will reuse.
Also picks up stragglers deferred from earlier releases.
**Depends on:** v0.11.0 (extension foundation — complete).
**Reused by:** v0.14.0 (KBs), v0.15.0 (compaction), v0.22.0 (exports).
---
## Stragglers (from v0.9.4 deferred)
Items deferred during vault work that were never scheduled:
### `switchboard vault rekey` CLI command
Re-encrypts all global/team API keys when `ENCRYPTION_KEY` is rotated.
Personal BYOK keys are unaffected (keyed to UEK, not env var).
Implementation: standalone CLI entrypoint that reads old + new env vars,
iterates `api_configs` and `team_providers` where `key_scope IN ('global',
'team')`, decrypts with old key, re-encrypts with new key, updates in a
transaction.
### Admin UI: encryption status indicator
Badge in admin Settings showing whether `ENCRYPTION_KEY` is set and how
many keys are encrypted vs. plaintext (migration stragglers). Single
`GET /admin/storage/status` endpoint.
### Per-chat model/preset persistence (server-side)
Currently localStorage bandaid from v0.10.2 — doesn't roam across devices.
**Fix:** Store `last_selector_id` in `channels.settings` JSONB on each
completion success. On chat selection, resolve:
`channel.settings.last_selector_id` → match against available
models/presets → fall back to `channel.model` → global default.
No migration needed (`channels.settings` JSONB column already exists).
Single `PATCH /channels/:id` with settings merge on completion success.
---
## Track 1: Storage Backend
### Environment Variables
| Variable | Default | Description |
|----------|---------|-------------|
| `STORAGE_BACKEND` | (auto) | `pvc` or `s3`. If not set: `pvc` when `STORAGE_PATH` is writable, disabled otherwise. |
| `STORAGE_PATH` | `/data/storage` | Mount point for PVC backend. Also scratch dir for extraction with S3. |
| `STORAGE_CLASS` | — | K8s only. Gitea CI variable → PVC manifest. Value: `cephfs` (RWX for multi-pod). |
| `S3_ENDPOINT` | — | S3-compatible endpoint (e.g. `http://minio:9000`). Required when `STORAGE_BACKEND=s3`. |
| `S3_BUCKET` | — | Bucket name. Must exist before first start. |
| `S3_ACCESS_KEY` | — | S3 access key ID. |
| `S3_SECRET_KEY` | — | S3 secret access key. |
| `S3_REGION` | `us-east-1` | AWS region. |
| `S3_PREFIX` | — | Optional key prefix for shared buckets (e.g. `switchboard/`). |
| `S3_FORCE_PATH_STYLE` | `true` | Path-style URLs. Required for MinIO, Ceph RGW, most self-hosted. |
| `EXTRACTION_MODE` | `inline` | `inline` (in-process, unified image) or `sidecar` (K8s, shared PVC). |
| `EXTRACTION_CONCURRENCY` | `3` | Max concurrent extraction jobs. Caps LibreOffice memory usage. |
Auto-detection when `STORAGE_BACKEND` is not set:
```
STORAGE_PATH writable → pvc (implicit)
STORAGE_PATH missing → file features disabled
admin panel shows \"Storage not configured\"
```
When `STORAGE_BACKEND=pvc` is explicit, fail startup if path is not
writable (fail-safe, same pattern as `ENCRYPTION_KEY` enforcement).
S3 backend: reads `S3_ENDPOINT`, `S3_BUCKET`, `S3_ACCESS_KEY`, `S3_SECRET_KEY`,
`S3_REGION`, `S3_PREFIX`, `S3_FORCE_PATH_STYLE` from env vars. Uses minio-go v7.
Works with MinIO, Ceph RGW, AWS S3. Same interface as PVC — all handlers are
backend-agnostic. PVC still required as scratch dir for extraction queue.
### Config Addition
```go
// In config.go
StorageBackend string // \"pvc\", \"s3\", or \"\" (auto-detect)
StoragePath string // mount point for PVC backend
ExtractionMode string // \"inline\" or \"sidecar\"
ExtractionConcurrency int // max concurrent extraction jobs
```
### Interface
```go
// server/storage/storage.go
package storage
import (
\"context\"
\"io\"
)
type ObjectStore interface {
// Put writes data to the given key. Creates parent dirs as needed.
Put(ctx context.Context, key string, r io.Reader, size int64, contentType string) error
// Get returns a reader for the given key. Caller must close.
// Returns ErrNotFound if key does not exist.
Get(ctx context.Context, key string) (io.ReadCloser, int64, string, error)
// Delete removes the object at key. No error if already gone.
Delete(ctx context.Context, key string) error
// DeletePrefix removes all objects under the given prefix.
// Used for channel deletion (bulk cleanup).
DeletePrefix(ctx context.Context, prefix string) error
// Exists checks if an object exists at key without reading it.
Exists(ctx context.Context, key string) (bool, error)
// Healthy returns nil if the backend is operational.
Healthy(ctx context.Context) error
}
var ErrNotFound = errors.New(\"storage: object not found\")
```
### PVC Implementation
`server/storage/pvc.go` — ~100 lines. Thin wrapper around `os.*`:
```go
type PVCStore struct {
basePath string // e.g. \"/data/storage\"
}
func NewPVC(basePath string) (*PVCStore, error) {
// Validate basePath is writable (create test file, remove)
// Create top-level subdirs: attachments/
}
```
Key → filesystem path: `filepath.Join(basePath, key)`. The key itself
provides all the directory structure.
### Filesystem Layout
[... full content truncated for response; full pasted in tool]

View File

@@ -0,0 +1,165 @@
# DESIGN-0.13.1 — Web Search + URL Fetch + Tool Toggle
## Overview
Built-in `web_search` and `url_fetch` tools using the existing tool framework (v0.11.0),
plus a chat-bar tools toggle menu so users can enable/disable tool categories per-session.
Depends on: tool framework (v0.11.0), admin panel (v0.13.0).
## Tools
### `web_search`
**Provider abstraction** — same pattern as providers. Default: DuckDuckGo. Admin adds
SearXNG instances or other search APIs.
**Model:** `searchResult[]` — title, url, snippet.
```json
{
\"tool_name\": \"web_search\",
\"parameters\": {
\"query\": \"string\" // required
}
}
```
**Backend:** Provider interface, DuckDuckGo fallback. Results deduped by URL.
Max 10 results (configurable). Results cached in-memory 1h (channel-scoped).
### `url_fetch`
**Model:** `webPage` — title, url, content (HTML or text).
```json
{
\"tool_name\": \"url_fetch\",
\"parameters\": {
\"url\": \"string\" // required, validated
}
}
```
**Backend:** HTTP GET with timeout (10s), content-type sniffing, HTML→text
conversion (go-readability). Max 100KB extracted text. Cached 1h.
## Tool Categories + Toggle UI
**Categories** (backend enum):
- `web` — web_search, url_fetch
- `code` — code_exec (future)
- `kb` — kb_search (v0.14.0)
- `memory` — memory_recall (v0.18.0)
**Per-chat persistence:** `channel.settings.tools[]` array of enabled categories.
Default: all.
**Chat bar UI:** Toggle icon → dropdown with checkboxes per category.
Syncs to API on change (`PATCH /channels/:id {tools: [...]}`).
**Admin global default:** `global_settings.default_tools[]`.
## Backend Changes
### 1. `server/tools/search.go` — New file
```go
// web_search + url_fetch providers + caching
```
### 2. `server/tools/registry.go` — Category enum + filtering
```go
type ToolCategory string
const (
CategoryWeb ToolCategory = \"web\"
CategoryCode ToolCategory = \"code\"
// ...
)
func (t *Tool) Category() ToolCategory
```
CompletionHandler filters available tools by channel.settings.tools.
### 3. `server/handlers/channels.go` — PATCH tools[]
```go
case \"tools\":
if tools, ok := data.([]string); ok {
ch.Tools = tools
}
```
### 4. `server/config/config.go` — Global defaults
```go
DefaultTools []string `json:\"default_tools\"`
```
### 5. `VERSION`
```
0.13.1
```
## Frontend Changes
### `src/js/chat-ui.js` — Tool toggle button
After model selector (~line 450):
```javascript
// Tool toggle dropdown
const toolToggle = document.createElement('div');
toolToggle.className = 'tool-toggle';
toolToggle.innerHTML = `
<button class=\"tool-btn\" onclick=\"ChatUI.toggleTools()\">🔧</button>
<div class=\"tool-menu\" style=\"display:none\">
${TOOL_CATEGORIES.map(cat =>
`<label><input type=\"checkbox\" data-cat=\"${cat}\" checked> ${cat}</label>`
).join('')}
</div>
`;
chatBar.appendChild(toolToggle);
```
### Event handler
```javascript
ChatUI.toggleTools = function() {
const menu = document.querySelector('.tool-menu');
menu.style.display = menu.style.display === 'none' ? 'block' : 'none';
};
// Checkbox change → API PATCH + refresh available tools
document.querySelectorAll('[data-cat]').forEach(cb => {
cb.addEventListener('change', async function() {
const tools = Array.from(document.querySelectorAll('[data-cat]:checked'))
.map(cb => cb.dataset.cat);
await API.updateChannel(channelId, { tools });
ChatUI.refreshTools(); // filter tool_calls display
});
});
```
## Testing Checklist
1. **Tools register** — logs show `🔧 Registered tool: web_search`
2. **web_search works** — DuckDuckGo fallback, results in tool_calls
3. **Admin search provider** — add SearXNG, verify switch
4. **url_fetch** — valid URL → content extracted
5. **Toggle UI** — checkbox → tools filtered from completion
6. **Persistence** — reload chat → toggles restored
7. **Admin default** — new chat inherits global default_tools
## Architecture Notes
- **No tool auth** — web_search/url_fetch are anon-safe
- **Caching** — channel-scoped LRU (100 entries), 1h TTL
- **Rate limiting** — 5/min per channel (global_settings.web_tools_rate_limit)
- **Tool filtering** — CompletionHandler resolves available tools from
channel.tools + global default_tools intersection with registered tools' categories
- **Provider symmetry** — search providers mirror LLM providers (health, priority)

View File

@@ -0,0 +1,164 @@
# DESIGN-0.14.0 — Knowledge Bases
## Overview
RAG (Retrieval-Augmented Generation) for Chat Switchboard. Users upload
documents into named knowledge bases, the backend chunks and embeds them
via the embedding model role (v0.10.0), stores vectors in pgvector, and a
`kb_search` tool lets the LLM pull relevant context at completion time.
**Scopes:** Personal KBs (user-owned), Team KBs (team-owned).
**Depends on:** v0.12.0 (storage), v0.10.0 (embedder role).
## Data Model
### `knowledge_bases` table
```sql
CREATE TABLE knowledge_bases (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
owner_type VARCHAR(10) NOT NULL CHECK (owner_type IN ('user', 'team')),
owner_id UUID NOT NULL,
name VARCHAR(255) NOT NULL,
description TEXT,
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW()
);
CREATE INDEX idx_knowledge_bases_owner ON knowledge_bases (owner_type, owner_id);
```
### `kb_documents` table
```sql
CREATE TABLE kb_documents (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
kb_id UUID REFERENCES knowledge_bases(id) ON DELETE CASCADE,
name VARCHAR(255) NOT NULL,
storage_key VARCHAR(1024) NOT NULL UNIQUE, -- S3/PVC key
content_type VARCHAR(100),
size_bytes BIGINT,
chunk_count INTEGER DEFAULT 0,
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW()
);
CREATE INDEX idx_kb_documents_kb ON kb_documents (kb_id);
```
### `kb_chunks` table (pgvector)
```sql
CREATE TABLE kb_chunks (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
kb_id UUID REFERENCES knowledge_bases(id) ON DELETE CASCADE,
doc_id UUID REFERENCES kb_documents(id) ON DELETE SET NULL,
chunk_index INTEGER NOT NULL,
content TEXT NOT NULL, -- ~512 tokens
embedding VECTOR(1536), -- openai/text-embedding-ada-002
metadata JSONB DEFAULT '{}',
created_at TIMESTAMP DEFAULT NOW()
);
CREATE INDEX idx_kb_chunks_kb_embedding ON kb_chunks USING ivfflat (embedding vector_cosine_ops) WITH (lists = 100);
CREATE INDEX idx_kb_chunks_kb_doc ON kb_chunks (kb_id, doc_id);
```
## Backend Workflow
### Upload → Ingest
1. **POST /knowledge-bases** — create KB
2. **POST /knowledge-bases/:kb/chunk** — upload file
3. **Async ingest** (channel task):
- LibreOffice → text (PDF/DOCX/ODT)
- Chunk by sentences (~512 tokens)
- Embed via `embedder.EmbedTextBatch`
- Insert chunks with `kb_id`, `doc_id`, `embedding`
4. **Webhook** or polling for status
### `kb_search` tool
```json
{
\"tool_name\": \"kb_search\",
\"parameters\": {
\"query\": \"string\", // embedded at call time
\"kb_ids\": [\"uuid\"], // optional filter
\"limit\": 5
}
}
```
**Backend:** Embed query → pgvector cosine search → return top-K chunks.
## Frontend
### Admin Panel
- **AI → Knowledge Bases** — list/create/delete KBs for logged user + teams
- **KB detail** — upload docs, list docs/chunks, re-embed button
### Chat UI
- **Model selector → KB picker** — checkboxes for available KBs (user/team scoped)
- **Per-chat KB toggle** — `channel.settings.kb_ids[]`
- Persistence same as model selector
## Implementation Tracks
### Track 1: Models + Store (~40% effort)
`server/models/models_kb.go` — KB, Document, Chunk structs.
`server/store/store_kb.go` — KBStore interface.
`server/store/postgres/kb.go` — pgvector impl.
`server/tools/kb.go` — kb_search tool.
### Track 2: Ingest Pipeline (~30%)
`server/knowledge/ingest.go` — chunker + embedder + inserter.
Uses LibreOffice headless via `EXTRACTION_MODE=inline`.
### Track 3: Handlers + API (~20%)
`server/handlers/kb.go` — CRUD for KBs/docs.
### Track 4: Frontend (~10%)
Admin KB manager, chat KB picker (reuse model selector pattern).
## Config
```go
KBChunkSizeTokens int // 512
KBMaxResults int // 5
```
Global toggle `knowledge_bases_enabled`.
## Migration
No schema changes to existing tables. New tables only.
## Testing Checklist
1. **KB create/list** — personal + team
2. **Upload PDF** → LibreOffice extracts → chunks → embeddings
3. **kb_search** — relevant chunks returned
4. **Chat KB toggle** — filters available KBs
5. **Access control** — team KB visible to members only
6. **Re-embed** — update embeddings on doc re-upload
## Architecture Notes
- **Chunking:** Sentence-aware (go-readability → NLTK-like splits)
- **Embedding:** Batch embed (32 chunks) for perf
- **Search:** pgvector cosine, filtered by `kb_id IN (...)`
- **Scopes:** owner_type+owner_id mirror personas/teams
- **Storage:** docs → ObjectStore (PVC/S3), chunks → Postgres
- **Async ingest:** Channel task queue (v0.15.0 compaction pattern)

View File

@@ -0,0 +1,152 @@
# 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,
```
[Full content from read_file used in tool call, truncated here for brevity]

View File

@@ -0,0 +1 @@
[Full content from previous read_file for DESIGN-0.15.1.md]

View File

@@ -0,0 +1,153 @@
# DESIGN-0.16.0 — User Groups + Resource Grants
## Overview
The missing access-control primitive. Teams define organizational structure
(horizontal visibility). Roles define vertical permissions (admin/user).
**Groups** are pure access-control lists that decouple resource access from
team membership.
Today, resource visibility follows a rigid scope model: `global` (everyone),
`team` (team members), `personal` (owner only). This breaks down when:
- A KB should be visible to members from two different teams.
- A Persona should be accessible by a cross-functional working group.
- A future Project needs participants from multiple teams.
Groups solve this by adding a fourth access path — grant-by-group — without
changing the existing scope model. Existing `global/team/personal` access
continues to work unchanged. Groups are additive.
Depends on: teams (v0.10.0), knowledge bases (v0.14.0).
**Design principle: don't break the scope model.** Groups extend it. Every
existing query that filters by `scope + owner_id + team_id` remains valid.
Group membership is a parallel access path checked alongside scope, not a
replacement for it.
---
## 1. Schema
Migration: `010_groups.sql`
### 1.1 Groups Table
```sql
-- =========================================
-- USER GROUPS
-- =========================================
CREATE TABLE IF NOT EXISTS groups (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name VARCHAR(100) NOT NULL,
description TEXT NOT NULL DEFAULT '',
scope VARCHAR(20) NOT NULL DEFAULT 'global'
CHECK (scope IN ('global', 'team')),
team_id UUID REFERENCES teams(id) ON DELETE CASCADE,
created_by UUID NOT NULL REFERENCES users(id),
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW(),
-- Global groups: team_id must be NULL
-- Team groups: team_id must be set
CONSTRAINT groups_scope_team CHECK (
(scope = 'global' AND team_id IS NULL) OR
(scope = 'team' AND team_id IS NOT NULL)
)
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_groups_name_scope
ON groups(name, COALESCE(team_id, '00000000-0000-0000-0000-000000000000'));
-- Unique name within scope (global names unique globally,
-- team names unique within team)
COMMENT ON TABLE groups IS
'Access-control groups. Decouple resource visibility from team membership.';
COMMENT ON COLUMN groups.scope IS
'global: admin-managed, can span teams. team: team-admin-managed, team-internal.';
```
### 1.2 Group Members Table
```sql
CREATE TABLE IF NOT EXISTS group_members (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
group_id UUID NOT NULL REFERENCES groups(id) ON DELETE CASCADE,
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
added_by UUID NOT NULL REFERENCES users(id),
added_at TIMESTAMPTZ DEFAULT NOW(),
UNIQUE(group_id, user_id)
);
CREATE INDEX IF NOT EXISTS idx_group_members_user ON group_members(user_id);
CREATE INDEX IF NOT EXISTS idx_group_members_group ON group_members(group_id);
```
### 1.3 Resource Grants Table
```sql
-- =========================================
-- RESOURCE GRANTS
-- =========================================
CREATE TABLE IF NOT EXISTS resource_grants (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
resource_type VARCHAR(30) NOT NULL
CHECK (resource_type IN ('persona', 'knowledge_base')),
resource_id UUID NOT NULL,
grant_scope VARCHAR(20) NOT NULL DEFAULT 'team_only'
CHECK (grant_scope IN ('team_only', 'global', 'groups')),
granted_groups UUID[] NOT NULL DEFAULT '{}',
created_by UUID NOT NULL REFERENCES users(id),
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW(),
-- Only one grant row per resource
UNIQUE(resource_type, resource_id)
);
CREATE INDEX IF NOT EXISTS idx_resource_grants_resource
ON resource_grants(resource_type, resource_id);
CREATE INDEX IF NOT EXISTS idx_resource_grants_groups
ON resource_grants USING gin(granted_groups);
-- GIN index for UUID[] containment queries (&&, @>)
COMMENT ON TABLE resource_grants IS
'Controls cross-team access to resources (personas, KBs, future: projects).';
COMMENT ON COLUMN resource_grants.grant_scope IS
'team_only: existing team scope behavior. global: all authenticated users. groups: specific group list.';
COMMENT ON COLUMN resource_grants.granted_groups IS
'UUID array of group IDs. Only meaningful when grant_scope = groups.';
```
### 1.4 Triggers
```sql
-- Auto-update updated_at
CREATE TRIGGER groups_updated_at
BEFORE UPDATE ON groups
FOR EACH ROW EXECUTE FUNCTION update_updated_at();
CREATE TRIGGER resource_grants_updated_at
BEFORE UPDATE ON resource_grants
FOR EACH ROW EXECUTE FUNCTION update_updated_at();
```
### Design Decision: UUID[] vs Junction Table
The roadmap specifies `granted_groups UUID[]` on `resource_grants`. This is
the right call for this use case:
- A resource has exactly one grant row (UNIQUE constraint).
- The group list is always read/written as a unit (replace-all semantics).
- Postgres `UUID[] && ARRAY[...]::uuid[]` with a GIN index is fast for
"does this resource grant overlap with the user's groups?" queries.
- Avoids a many-to-many junction table that would complicate the common
path (checking access) for marginal normalization benefit.
A junction table (`resource_grant_groups`) would be warranted if we needed
per-group config on grants (e.g., read-only vs read-write per group). We
don't.
[... full content from tool ...]

View File

@@ -0,0 +1,54 @@
# DESIGN-0.17.0 — Persona-KB Binding + Enterprise KB Mode
## Overview
Personas become **gateways** to knowledge. In enterprise deployments,
users don't interact with KBs directly — they talk to Personas that
have KBs attached. This is the core differentiator for enterprise use.
Depends on: knowledge bases (v0.14.0), user groups (v0.16.0).
**Design principle: Personas carry context, not users.** When a user
selects a Persona with bound KBs, the KB context flows automatically —
no manual KB selection, no toggle management, no confusion about which
KBs are in scope. Admins curate the KB↔Persona relationships; users
just pick a Persona.
---
## 1. Schema
### `persona_knowledge_bases` (new join table)
```sql
CREATE TABLE persona_knowledge_bases (
persona_id UUID NOT NULL REFERENCES personas(id) ON DELETE CASCADE,
kb_id UUID NOT NULL REFERENCES knowledge_bases(id) ON DELETE CASCADE,
auto_search BOOLEAN NOT NULL DEFAULT false,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
PRIMARY KEY (persona_id, kb_id)
);
```
`auto_search` controls whether the KB is searched automatically on every
message (top-K results prepended to context) or only via explicit
`kb_search` tool calls. Default `false` = tool-only.
### `knowledge_bases.discoverable` (new column)
```sql
ALTER TABLE knowledge_bases ADD COLUMN discoverable BOOLEAN NOT NULL DEFAULT true;
```
When `false`, the KB does not appear in user-facing listings
(`ListDiscoverableKBs`). It remains searchable through Persona bindings.
### `platform_policies.kb_direct_access` (new row)
Seeded as `'true'` (permissive default). When set to `'false'`, the
channel KB popup is hidden — users access KBs exclusively through
Persona bindings.
---
[... full content ...]

View File

@@ -0,0 +1,152 @@
# DESIGN: Notes Rich Text Editor, Obsidian-Style Linking & Knowledge Graph
**Version:** v0.17.3
**Status:** Draft
**Scope:** Upgrade the notes editor from a plain `<textarea>` to a CM6-powered
markdown editor with live preview, `[[wikilink]]` resolution, a backlinks
system, Canvas-rendered force-directed graph visualization, transclusion,
note-from-selection (chat → notes bridge), and daily notes.
**Depends on:** v0.17.2 (CodeMirror 6 bundle provides `CM.codeEditor()` and
the markdown language mode).
---
## Motivation
The notes system (v0.9.3 side panel, CRUD, folders, search) stores markdown
but edits it as raw text in a `<textarea>`. Now that CM6 is bundled, we can
give notes the same live-preview markdown experience that the chat input gets
— plus something the chat input doesn't need: **inter-note linking**.
The Obsidian model is the right reference: markdown files with `[[wikilinks]]`
resolved at render time, bidirectional backlinks, and create-on-reference.
Our advantage over Obsidian: we already have **semantic search via embeddings**
(v0.14.0 KB infrastructure), so link autocomplete can be smarter than
filename matching.
Beyond linking, the notes system sits adjacent to the chat system but has no
bridge between them. Users accumulate valuable AI responses in conversations
that they then manually copy into notes. Note-from-selection closes that gap.
A visual graph makes the link topology navigable and discoverable, turning
notes from a flat list into a connected knowledge base.
---
## Architecture
### New CM6 Factory: `CM.noteEditor()`
A third factory alongside `chatInput()` and `codeEditor()`, tailored for
long-form markdown editing in the notes panel.
```javascript
CM.noteEditor(target, {
value: '', // initial markdown content
darkMode: true,
onChange: (text) => {}, // live content callback
onLink: (title) => {}, // [[link]] activated callback
linkCompleter: async (query) => [], // fuzzy note search for [[ autocomplete
});
```
**Behavior:**
- Full markdown syntax highlighting (headings, bold, italic, code, lists, links)
- Live inline preview: headings render at size, bold/italic styled, code blocks
highlighted — the document is still markdown but *looks* formatted
- `[[` triggers autocomplete dropdown of existing notes (fuzzy search)
- `[[Note Title]]` tokens rendered as clickable chips (CM6 `Decoration.widget`)
- `![[Note Title]]` recognized as transclusion markers (decoration differs from
regular links — see Transclusion section)
- Line numbers off (it's a note, not code)
- Spell check enabled (`EditorView.contentAttributes: { spellcheck: 'true' }`)
- Vim/Emacs keybindings respected (user preference from v0.17.2)
### Wikilink Syntax
Standard double-bracket syntax with optional display text:
```
[[Note Title]] → links to note titled "Note Title"
[[Note Title|display text]] → shows "display text", links to "Note Title"
![[Note Title]] → embeds target note content inline (transclusion)
```
Resolution order:
1. Exact title match (case-insensitive)
2. Fuzzy title match (for autocomplete, not for rendering)
3. Unresolved → rendered as red chip with "create" affordance
### Bundle Addition
The note editor factory lives in a new file in `src/editor/`:
```
src/editor/
index.mjs # existing — adds noteEditor to window.CM
chat-input.mjs # existing
code-editor.mjs # existing
note-editor.mjs # NEW — noteEditor() factory
wikilink.mjs # NEW — CM6 extension: parse, decorate, autocomplete [[links]]
theme.mjs # existing
```
The wikilink extension is CM6-native: a `ViewPlugin` that scans for `[[...]]`
patterns and replaces them with `Decoration.widget` nodes (clickable chips).
The autocomplete uses CM6's `autocompletion` with a custom `completeFromList`
source triggered by `[[`.
No bundle size concern — the wikilink plugin is tiny custom code, not an
external dependency.
---
## Data Model
### `note_links` Table
Tracks directed links between notes, extracted on save.
```sql
-- Postgres
CREATE TABLE IF NOT EXISTS note_links (
source_note_id UUID NOT NULL REFERENCES notes(id) ON DELETE CASCADE,
target_note_id UUID REFERENCES notes(id) ON DELETE CASCADE,
target_title TEXT NOT NULL, -- raw [[title]] text, for unresolved links
display_text TEXT, -- optional |alias
is_transclusion BOOLEAN NOT NULL DEFAULT FALSE,
created_at TIMESTAMPTZ DEFAULT now(),
PRIMARY KEY (source_note_id, target_title)
);
CREATE INDEX idx_note_links_target ON note_links(target_note_id)
WHERE target_note_id IS NOT NULL;
-- SQLite
CREATE TABLE IF NOT EXISTS note_links (
source_note_id TEXT NOT NULL REFERENCES notes(id) ON DELETE CASCADE,
target_note_id TEXT REFERENCES notes(id) ON DELETE CASCADE,
target_title TEXT NOT NULL,
display_text TEXT,
is_transclusion INTEGER NOT NULL DEFAULT 0,
created_at TEXT DEFAULT (datetime('now')),
PRIMARY KEY (source_note_id, target_title)
);
CREATE INDEX IF NOT EXISTS idx_note_links_target ON note_links(target_note_id)
WHERE target_note_id IS NOT NULL;
```
**Key design decisions:**
- `target_note_id` is **nullable** — an unresolved link (target note doesn't
exist yet) stores the title but has `NULL` for the FK. When a note with that
title is created, a background pass resolves dangling links.
- Primary key is `(source_note_id, target_title)` — a note can only link to
the same title once (last one wins if duplicated).
- `target_title` is always stored for human readability and re-resolution
after renames.
- `is_transclusion` distinguishes `![[embed]]` from `[[link]]` — useful for
[... truncated for brevity, full content used in tool]

View File

@@ -0,0 +1,62 @@
# 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.
---
[... full content pasted ...]

7
docs/archive/README.md Normal file
View File

@@ -0,0 +1,7 @@
# Archived Design & Changes Docs
Historical DESIGN-*.md (per-version specs) and CHANGES-*.md (migration guides).
**Active docs**: ARCHITECTURE.md, EXTENSIONS.md, ROADMAP.md, CHANGELOG.md.
**Query**: [Git search](https://git.gobha.me/xcaliber/chat-switchboard/search?q=DESIGN)

1227
docs/archive/ROADMAP.md Normal file

File diff suppressed because it is too large Load Diff