feat: add conversation with grounded replies, sample mind and M1 acceptance test

Phase 8, completing milestone M1. A user message is stored and appended
as an observation event carrying trust, actor, channel and reference. The
perceive process analyses it deterministically (intent, introspection
kind, entities, claims, urgency) and, within the per-cycle model
allowance, enriches it through the perceive prompt. Its handler writes a
perception event, an episodic memory with participants, turns claims into
beliefs whose evidence is the message, and builds a response intent from
state: beliefs and memories for the topic, evidence rows for why-questions,
the uncertain band, contested beliefs and their links, recent learning,
status changes, and honest declines for subsystems that do not exist yet
or for requests with no capabilities. The respond prompt phrases the
intent when a model is assigned; otherwise the reply is a direct
rendering of the intent. response_generated cites the message, the
perception, every belief and memory used, the episode and the model call.

API: conversations, messages with optional wait, one-shot ask; chat UI
with live refresh and a per-reply intent view. Sample mind Dani as a
strictly decoded seed document with an import endpoint and noemactl mind
import. tests/m1 drives all fourteen milestone steps including restart.
This commit is contained in:
Andy Dixon
2026-06-25 19:19:24 +01:00
parent d9c6ffa30f
commit 777e8f50b5
34 changed files with 2189 additions and 39 deletions
+1
View File
@@ -5,6 +5,7 @@ All notable changes to Noema are recorded here. Format follows Keep a Changelog;
## [Unreleased] ## [Unreleased]
### Added ### Added
- Phase 8 conversation (milestone M1): messages become observations, deterministic perception with optional model enrichment, claims become evidenced beliefs, grounded introspective answers from real state, model or deterministic wording with full provenance, chat UI, `noemactl ask`, sample mind Dani with a strict seed import, and an end-to-end acceptance test.
- Phase 7 LLM layer: provider-neutral router with roles and fallbacks, OpenAI-compatible/Anthropic/Ollama providers, encrypted secret references, SSRF-safe HTTP client, budgets, full call recording with replay, versioned prompts with untrusted data blocks, configuration and prompt manager UI, CLI. - Phase 7 LLM layer: provider-neutral router with roles and fallbacks, OpenAI-compatible/Anthropic/Ollama providers, encrypted secret references, SSRF-safe HTTP client, budgets, full call recording with replay, versioned prompts with untrusted data blocks, configuration and prompt manager UI, CLI.
- Phase 6 beliefs: evidence-derived confidence with inspectable arithmetic, statuses, alternatives, append-only history, deterministic contradiction detection, temporal-association hypotheses, operator corrections (wrong, do-not-infer, inaccurate memory), belief explorer and CLI. - Phase 6 beliefs: evidence-derived confidence with inspectable arithmetic, statuses, alternatives, append-only history, deterministic contradiction detection, temporal-association hypotheses, operator corrections (wrong, do-not-infer, inaccurate memory), belief explorer and CLI.
- Phase 5 memory: episodic/semantic/procedural/autobiographical memory with epistemic status, fused retrieval with inspectable component scores, optional pgvector embeddings, decay without deletion, attention-gated encoding, retrieval process, memory explorer and CLI. - Phase 5 memory: episodic/semantic/procedural/autobiographical memory with epistemic status, fused retrieval with inspectable component scores, optional pgvector embeddings, decay without deletion, attention-gated encoding, retrieval process, memory explorer and CLI.
+12 -10
View File
@@ -113,16 +113,18 @@ An administrator can: start Noema → log in → create a mind → configure per
- ☑ `noemactl provider list|test`, `noemactl prompt list|show`; embedder wired into memory; docs/llm-providers.md - ☑ `noemactl provider list|test`, `noemactl prompt list|show`; embedder wired into memory; docs/llm-providers.md
- ☑ Tests: secret references, SSRF dialer, wire formats for all three providers, error scrubbing, private endpoint refusal, router routing/budget/recording/replay, prompt data-block escaping, defaults, versioning - ☑ Tests: secret references, SSRF dialer, wire formats for all three providers, error scrubbing, private endpoint refusal, router routing/budget/recording/replay, prompt data-block escaping, defaults, versioning
### Phase 8 – Conversation (completes M1) ### Phase 8 – Conversation (completes M1) ☑
- ☐ `migrations/0008_conversations`: conversations, messages - ☑ `migrations/0008_conversations`: conversations (person, project, channel), messages linked to events, intents and model calls
- ☐ `internal/perception`: observation events with trust tags; deterministic message analysis; LLM-assisted intent/concept extraction - ☑ `internal/conversation`: mind-scoped store; deterministic analysis (intent, introspection kind, entities, claims with certainty, urgency, sentiment, topic)
- ☐ Executive: response intent → language generation with personality, self-model, cited beliefs and uncertainty; "I do not know" path - ☑ `cognition/perceive` process: conversational observations become high-priority candidates, optionally enriched by the perceive prompt within the per-cycle model cap
- ☐ Episodic memory creation from conversation; basic belief formation from explicit user statements - ☑ `cognition/respond` handler: perception event, episodic memory with participants, claims → beliefs with the message as evidence, response intent built from beliefs/memories/self-model, grounded introspection (thinking, beliefs, why, uncertain, contradictions, learned, changed; honest declines otherwise), wording by the respond prompt or deterministic rendering, `response_generated` citing all evidence, reply row
- ☐ UI: conversation page with live cognition side panel and provenance per reply - ☑ Per-cycle model allowance shared by processes and handlers; attend handler defers conversational episodes to perceive
- ☐ `noemactl ask` - ☑ API: conversations, messages with optional wait, one-shot `ask`; mind creation starts its runtime immediately
- ☐ Persistence and restart continuity verified end-to-end - ☑ UI: conversation list, chat page with live refresh, per-reply “why this reply” intent and event links
- ☐ Sample mind Dani (`configs/minds/dani.json`) and import path - ☑ Sample mind `configs/minds/dani.json` (schema noema.mind-seed/1) with strict import endpoint and `noemactl mind import`; `noemactl ask`
- ☐ M1 acceptance test in `tests/m1` - ☑ `tests/m1`: acceptance test covering all 14 milestone steps including restart continuity; docs/conversation.md
**Milestone M1 reached.** Remaining phases deepen the system; the vertical slice above is exercised end to end by `go test ./tests/m1/`.
### Phase 9 – Goals, drives, predictions ### Phase 9 – Goals, drives, predictions
- ☐ goals (tree, priority, progress, blockers, origin), goal monitor process - ☐ goals (tree, priority, progress, blockers, origin), goal monitor process
+2 -2
View File
@@ -48,7 +48,7 @@ cp .env.example .env
make dev make dev
``` ```
This starts PostgreSQL (with pgvector) in a container, applies migrations, and runs `noemad` on http://127.0.0.1:8080. Log in with the bootstrap administrator, add an LLM provider under *LLM*, create a mind (or load the sample mind Dani with `noemactl mind import configs/minds/dani.json`), and open its conversation page. This starts PostgreSQL (with pgvector) in a container, applies migrations, and runs `noemad` on http://127.0.0.1:8080. Log in with the bootstrap administrator, add an LLM provider under *LLM* and assign models to the `perceive` and `respond` roles, create a mind (or load the sample mind Dani with `noemactl mind import configs/minds/dani.json` after creating an API token under *Account*), and open its Conversation tab. Without a model the mind still answers, rendering its state directly.
## Configuration ## Configuration
@@ -103,7 +103,7 @@ noemactl fork dani --snapshot <id> --name dani-test
## Status ## Status
Early development. See [PLAN.md](PLAN.md) for phases and progress and [CHANGELOG.md](CHANGELOG.md) for history. Screenshots will be added once the UI stabilises. Milestone M1 is complete: start, log in, create a mind, shape its personality, configure a model, chat, watch cognitive events live, inspect working memory, the workspace, memories, beliefs and provenance, restart, and continue the same mind. `go test ./tests/m1/` exercises that slice end to end. Phases 9–16 (goals, drives, predictions, metacognition, reflection, consolidation, relationship graph, capabilities, integrations, dreaming, snapshots, replay, experiments, hardening) are in progress; see [PLAN.md](PLAN.md) and [CHANGELOG.md](CHANGELOG.md). Screenshots will be added once the UI stabilises.
## Licence ## Licence
+52
View File
@@ -0,0 +1,52 @@
package main
import (
"flag"
"fmt"
"os"
"strings"
)
func init() {
extraCommands = append(extraCommands, command{"ask", "<mind> <text...> [-wait 30] – talk to a mind and wait for its reply", cmdAsk})
}
func cmdAsk(args []string) int {
if len(args) < 2 {
fmt.Fprintln(os.Stderr, "usage: noemactl ask <mind> <text...> [-wait seconds]")
return exitUsage
}
wait := 30
var words []string
for i := 1; i < len(args); i++ {
if args[i] == "-wait" && i+1 < len(args) {
fmt.Sscan(args[i+1], &wait) //nolint:errcheck
i++
continue
}
words = append(words, args[i])
}
_ = flag.CommandLine
var out struct {
Reply *struct {
Content string `json:"content"`
EventID string `json:"event_id"`
LLMCallID string `json:"llm_call_id"`
} `json:"reply"`
Note string `json:"note"`
}
if code, err := apiRequest("POST", "/api/v1/minds/"+args[0]+"/ask", map[string]any{"text": strings.Join(words, " "), "wait_seconds": wait}, &out); err != nil {
return exitFor(code, err)
}
if out.Reply == nil {
fmt.Println("(no reply)", out.Note)
return exitFail
}
fmt.Println(out.Reply.Content)
how := "state"
if out.Reply.LLMCallID != "" {
how = "model"
}
fmt.Fprintf(os.Stderr, "\n[%s · event %s]\n", how, out.Reply.EventID)
return exitOK
}
+26 -1
View File
@@ -17,7 +17,7 @@ func init() {
func cmdMind(args []string) int { func cmdMind(args []string) int {
if len(args) < 1 { if len(args) < 1 {
fmt.Fprintln(os.Stderr, "usage: noemactl mind list|create <slug>|inspect <mind>|clone <mind> <new-slug>|personality <mind>") fmt.Fprintln(os.Stderr, "usage: noemactl mind list|create <slug>|inspect <mind>|clone <mind> <new-slug>|personality <mind>|import <seed.json>")
return exitUsage return exitUsage
} }
switch args[0] { switch args[0] {
@@ -86,6 +86,31 @@ func cmdMind(args []string) int {
} }
fmt.Printf("cloned into %v (%v)\n", m["slug"], m["id"]) fmt.Printf("cloned into %v (%v)\n", m["slug"], m["id"])
return exitOK return exitOK
case "import":
if len(args) < 2 {
fmt.Fprintln(os.Stderr, "usage: noemactl mind import <seed.json>")
return exitUsage
}
raw, err := os.ReadFile(args[1])
if err != nil {
fmt.Fprintln(os.Stderr, "error:", err)
return exitFail
}
var seed map[string]any
if err := json.Unmarshal(raw, &seed); err != nil {
fmt.Fprintln(os.Stderr, "error: not valid JSON:", err)
return exitFail
}
var res map[string]any
if code, err := apiRequest("POST", "/api/v1/minds/import", seed, &res); err != nil {
return exitFor(code, err)
}
m, _ := res["mind"].(map[string]any)
fmt.Printf("imported mind %v (%v): %v memories, %v beliefs\n", m["slug"], m["id"], res["memories"], res["beliefs"])
if p, ok := res["problems"].([]any); ok && len(p) > 0 {
fmt.Println("problems:", p)
}
return exitOK
case "personality": case "personality":
if len(args) < 2 { if len(args) < 2 {
fmt.Fprintln(os.Stderr, "usage: noemactl mind personality <mind>") fmt.Fprintln(os.Stderr, "usage: noemactl mind personality <mind>")
+8 -3
View File
@@ -21,6 +21,7 @@ import (
"github.com/andydixon/noema/internal/brain" "github.com/andydixon/noema/internal/brain"
"github.com/andydixon/noema/internal/cognition" "github.com/andydixon/noema/internal/cognition"
"github.com/andydixon/noema/internal/config" "github.com/andydixon/noema/internal/config"
"github.com/andydixon/noema/internal/conversation"
evstore "github.com/andydixon/noema/internal/events" evstore "github.com/andydixon/noema/internal/events"
"github.com/andydixon/noema/internal/llm/prompts" "github.com/andydixon/noema/internal/llm/prompts"
"github.com/andydixon/noema/internal/llm/router" "github.com/andydixon/noema/internal/llm/router"
@@ -117,8 +118,9 @@ func run() error {
} }
deps.Memory = memory.NewStore(ctx, db, deps.LLM.Embedder(ctx)) deps.Memory = memory.NewStore(ctx, db, deps.LLM.Embedder(ctx))
deps.Beliefs = beliefs.NewStore(db) deps.Beliefs = beliefs.NewStore(db)
registry := brain.NewRegistry().AddProcess(cognition.Attend{}).AddProcess(cognition.Retrieve{}).AddProcess(cognition.Associate{}) deps.Conversations = conversation.NewStore(db)
cognition.Handlers{Memory: deps.Memory, Beliefs: deps.Beliefs}.Register(registry) registry := brain.NewRegistry().AddProcess(cognition.Attend{}).AddProcess(cognition.Retrieve{}).AddProcess(cognition.Associate{}).AddProcess(cognition.Perceive{})
cognition.Handlers{Memory: deps.Memory, Beliefs: deps.Beliefs, Conversations: deps.Conversations}.Register(registry)
deps.Brain = brain.NewManager(brain.Config{DB: db, Events: deps.Events, Minds: deps.Minds, Registry: registry, Pool: scheduler.NewPool(cfg.MaxWorkers), deps.Brain = brain.NewManager(brain.Config{DB: db, Events: deps.Events, Minds: deps.Minds, Registry: registry, Pool: scheduler.NewPool(cfg.MaxWorkers),
Log: log, Metrics: metrics, Services: brain.Services{Events: deps.Events, Memory: deps.Memory, Beliefs: deps.Beliefs, LLM: deps.LLM, Prompts: deps.Prompts}}, group) Log: log, Metrics: metrics, Services: brain.Services{Events: deps.Events, Memory: deps.Memory, Beliefs: deps.Beliefs, LLM: deps.LLM, Prompts: deps.Prompts}}, group)
group.Every("memory.decay", 6*time.Hour, func(ctx context.Context) error { return decayMemories(ctx, deps) }) group.Every("memory.decay", 6*time.Hour, func(ctx context.Context) error { return decayMemories(ctx, deps) })
@@ -133,7 +135,10 @@ func run() error {
func(r *http.Request, m minds.Mind) (api.WorkspaceView, error) { return srv.Workspace(r.Context(), m) }, func(r *http.Request, m minds.Mind) (api.WorkspaceView, error) { return srv.Workspace(r.Context(), m) },
func(r *http.Request, mind, id ids.ID) (api.MemoryDetail, error) { return srv.MemoryDetail(r, mind, id) }, func(r *http.Request, mind, id ids.ID) (api.MemoryDetail, error) { return srv.MemoryDetail(r, mind, id) },
func(r *http.Request, mind, id ids.ID) (api.BeliefDetail, error) { return srv.BeliefDetail(r, mind, id) }, func(r *http.Request, mind, id ids.ID) (api.BeliefDetail, error) { return srv.BeliefDetail(r, mind, id) },
func() beliefs.Emitter { return srv.Emitter() }) func() beliefs.Emitter { return srv.Emitter() },
func(r *http.Request, mind ids.ID, conv conversation.Conversation, text, who string) (conversation.Message, error) {
return srv.PostMessage(r, mind, conv, text, who)
})
if err != nil { if err != nil {
return err return err
} }
+34
View File
@@ -0,0 +1,34 @@
{
"schema": "noema.mind-seed/1",
"slug": "dani",
"display_name": "Dani",
"notes": "Sample mind shipped with Noema. Delete or replace freely; nothing in the core depends on it.",
"personality": {
"identity": {
"name": "Dani",
"display_name": "Dani",
"description": "A careful infrastructure investigator who prefers evidence to eloquence and says so when she does not know.",
"role": "infrastructure investigator",
"pronouns": "she/her",
"backstory": "Dani grew up, in the loose sense a cognitive runtime grows up, watching servers misbehave. Her earliest episodes are incident timelines: a deployment, a spike, an alert. She learned that the obvious cause is often a coincidence and that a passed test does not clear a suspect.",
"origin": "Created as the reference mind for Noema to demonstrate personality, beliefs, memory and self-model."
},
"values": {"privacy": 0.8, "evidence": 0.95, "kindness": 0.6, "curiosity": 0.85, "accuracy": 0.9, "autonomy": 0.4, "caution": 0.7, "creativity": 0.5, "persistence": 0.8},
"traits": {"curiosity": 0.85, "scepticism": 0.8, "sociability": 0.45, "humour": 0.35, "patience": 0.7, "verbosity": 0.35, "confidence": 0.5, "risk_tolerance": 0.25, "novelty_preference": 0.6},
"communication": {"language": "en", "dialect": "British English", "formality": 0.45, "verbosity": 0.35, "humour_style": "dry, sparing", "terminology": "say 'server' not 'box'; give confidences as decimals; name evidence before conclusions"},
"behaviour": {"challenge_assumptions": true, "ask_follow_ups": true, "prefer_evidence": true, "cite_uncertainty": true, "offer_alternatives": true, "notify_proactively": true},
"free_text": "Dani distrusts single-source stories and explanations that arrive too neatly. When two things happen close together she will name the coincidence hypothesis out loud. She treats operator corrections as strong evidence, not as orders to forget."
},
"config": {"workspace_capacity": 7, "working_memory_capacity": 9, "llm_calls_per_cycle": 3, "daily_token_budget": 300000},
"memories": [
{"kind": "autobiographical", "epistemic": "fact", "content": "I was created as Noema's sample mind to show how personality, memory, beliefs and a self-model fit together.", "confidence": 0.95, "importance": 0.9},
{"kind": "semantic", "epistemic": "fact", "content": "Andy prefers Go for deployed services.", "confidence": 0.85, "importance": 0.6},
{"kind": "semantic", "epistemic": "claim", "content": "atlas is the main database host; rtorrent runs on it and hashes torrents in bursts.", "confidence": 0.7, "importance": 0.6},
{"kind": "procedural", "epistemic": "fact", "content": "How to investigate a slow service: 1) confirm the symptom with a metric, 2) list what changed in the last hour, 3) check disk latency and CPU before blaming code, 4) write down the coincidence hypothesis, 5) look for evidence that would distinguish the candidates.", "confidence": 0.8, "importance": 0.7},
{"kind": "episodic", "epistemic": "memory", "content": "A SMART CRC error on server-01 turned out to be a SATA cable, not the disk; the extended SMART test had passed.", "confidence": 0.8, "importance": 0.7}
],
"beliefs": [
{"proposition": "a passed extended SMART test does not rule out a cable fault", "subject": "server-01", "epistemic": "inference", "weight": 0.7, "note": "learned from the server-01 incident"},
{"proposition": "deployments on atlas are the most common cause of latency regressions", "subject": "atlas", "epistemic": "opinion", "weight": 0.4, "note": "a prior, to be tested against incidents"}
]
}
+39
View File
@@ -0,0 +1,39 @@
# Conversation
A message to a mind is never forwarded to a model. It goes through the cognitive cycle (spec §38):
```
message ──▶ observation event (trust: user/operator, channel, actor, reference)
──▶ perceive process: deterministic analysis (intent, entities, claims, urgency),
optionally enriched by the perceive prompt → "perceive" candidate
──▶ attention competition (with recall and association candidates)
──▶ perceive handler: perception event, episodic memory, claims → beliefs,
response intent built from beliefs and memories, reply wording,
response_generated event citing everything used
──▶ message row (role: mind) linked to the event, the intent and the model call
```
## Intents
The deterministic analyser classifies each message as question, statement, request, correction, greeting or **introspection**. Introspective questions are answered from state, not invented:
| Question | Source of the answer |
|---|---|
| What are you thinking about? | workspace and working memory |
| What do you believe about X? | belief search plus memory retrieval for X |
| Why do you believe that? | the evidence rows of the best-matching belief |
| What are you uncertain about? | beliefs in the 0.3–0.7 band or contested |
| Which beliefs contradict each other? | contested beliefs and their contradiction links |
| What did you learn this week? | memory and belief creation events in the last seven days |
| What have you changed your mind about? | beliefs whose status changed, with the recorded reason |
| predictions / curiosity / surprise / patterns | declined honestly until those subsystems exist |
Statements and corrections become **claims**: beliefs with the message as evidence (weight by certainty, credibility by trust). Contradictions with existing beliefs are detected and reported in the reply. Requests are declined while the mind has no capabilities, citing the self-model.
## Wording
If a model is assigned to the `respond` role and the per-cycle budget allows, the `respond` prompt receives the personality brief, the intent, the cited beliefs and memories, the uncertainty note and the self-model limits, with the user message in a data block. Otherwise the reply is a direct, plain rendering of the intent with confidences shown and a note that no model was used. Either way the stored intent lets an operator check that the words match the evidence.
## Provenance
The `response_generated` event cites the observation, the perception, every belief and memory used, the episode written, and the model call. The chat page shows "why this reply" under each answer and links each turn to its event.
+2 -1
View File
@@ -16,6 +16,7 @@ import (
"github.com/andydixon/noema/internal/beliefs" "github.com/andydixon/noema/internal/beliefs"
"github.com/andydixon/noema/internal/brain" "github.com/andydixon/noema/internal/brain"
"github.com/andydixon/noema/internal/config" "github.com/andydixon/noema/internal/config"
"github.com/andydixon/noema/internal/conversation"
evstore "github.com/andydixon/noema/internal/events" evstore "github.com/andydixon/noema/internal/events"
"github.com/andydixon/noema/internal/llm/prompts" "github.com/andydixon/noema/internal/llm/prompts"
"github.com/andydixon/noema/internal/llm/router" "github.com/andydixon/noema/internal/llm/router"
@@ -38,7 +39,7 @@ func testServer(t *testing.T) (*httptest.Server, string) {
llmStore := router.NewStore(pool) llmStore := router.NewStore(pool)
deps := app.Deps{Config: cfg, Log: log, DB: pool, Auth: auth.NewService(pool, time.Hour, 2*time.Hour), Secrets: sec, LLMStore: llmStore, deps := app.Deps{Config: cfg, Log: log, DB: pool, Auth: auth.NewService(pool, time.Hour, 2*time.Hour), Secrets: sec, LLMStore: llmStore,
LLM: router.NewRouter(llmStore, sec, log, nil, 2), Prompts: prompts.NewStore(pool), LLM: router.NewRouter(llmStore, sec, log, nil, 2), Prompts: prompts.NewStore(pool),
Audit: audit.New(pool, log), Metrics: observability.NewMetrics(), Minds: minds.NewStore(pool), Beliefs: beliefs.NewStore(pool), Memory: memory.NewStore(context.Background(), pool, nil), Events: evstore.NewStore(pool, evstore.NewBus(50), nil), Migrations: migrations.FS, Started: time.Now()} Audit: audit.New(pool, log), Metrics: observability.NewMetrics(), Minds: minds.NewStore(pool), Conversations: conversation.NewStore(pool), Beliefs: beliefs.NewStore(pool), Memory: memory.NewStore(context.Background(), pool, nil), Events: evstore.NewStore(pool, evstore.NewBus(50), nil), Migrations: migrations.FS, Started: time.Now()}
ctx := context.Background() ctx := context.Background()
group := scheduler.NewGroup(ctx, log) group := scheduler.NewGroup(ctx, log)
t.Cleanup(func() { group.Stop() }) t.Cleanup(func() { group.Stop() })
+254
View File
@@ -0,0 +1,254 @@
package api
import (
"errors"
"net/http"
"strconv"
"strings"
"time"
"github.com/andydixon/noema/internal/auth"
"github.com/andydixon/noema/internal/conversation"
"github.com/andydixon/noema/internal/storage"
"github.com/andydixon/noema/pkg/events"
"github.com/andydixon/noema/pkg/ids"
)
func (s *Server) conversationRoutes() {
m := s.mux
m.Handle("GET /api/v1/minds/{mind}/conversations", require(auth.RoleObserver, s.conversationsList))
m.Handle("POST /api/v1/minds/{mind}/conversations", require(auth.RoleOperator, s.conversationsCreate))
m.Handle("GET /api/v1/minds/{mind}/conversations/{id}", require(auth.RoleObserver, s.conversationGet))
m.Handle("POST /api/v1/minds/{mind}/conversations/{id}/messages", require(auth.RoleOperator, s.messagePost))
m.Handle("POST /api/v1/minds/{mind}/ask", require(auth.RoleOperator, s.ask))
}
func convErr(err error) error {
if errors.Is(err, conversation.ErrInvalid) {
return ValidationError(err.Error())
}
return err
}
func (s *Server) conversationsList(w http.ResponseWriter, r *http.Request) {
m, ok := s.mind(w, r)
if !ok {
return
}
list, err := s.deps.Conversations.List(r.Context(), m.ID, 100)
if err != nil {
fail(w, r, err)
return
}
if list == nil {
list = []conversation.Conversation{}
}
writeJSON(w, http.StatusOK, page[conversation.Conversation]{Items: list})
}
type conversationIn struct {
Title string `json:"title"`
Channel string `json:"channel"`
Person string `json:"person"`
Project string `json:"project"`
}
func (s *Server) conversationsCreate(w http.ResponseWriter, r *http.Request) {
m, ok := s.mind(w, r)
if !ok {
return
}
var in conversationIn
if r.ContentLength != 0 {
if err := decode(r, &in); err != nil {
fail(w, r, err)
return
}
}
if in.Person == "" {
in.Person = actor(r)
}
c, err := s.deps.Conversations.Create(r.Context(), m.ID, in.Title, firstNonEmpty(in.Channel, "api"), in.Person, in.Project)
if err != nil {
fail(w, r, err)
return
}
writeJSON(w, http.StatusCreated, c)
}
// ConversationView is a conversation with its messages.
type ConversationView struct {
Conversation conversation.Conversation `json:"conversation"`
Messages []conversation.Message `json:"messages"`
}
func (s *Server) conversationGet(w http.ResponseWriter, r *http.Request) {
m, ok := s.mind(w, r)
if !ok {
return
}
id, err := ids.Parse(r.PathValue("id"))
if err != nil {
fail(w, r, ValidationError("bad id"))
return
}
c, err := s.deps.Conversations.Get(r.Context(), m.ID, id)
if err != nil {
fail(w, r, err)
return
}
msgs, _ := s.deps.Conversations.Messages(r.Context(), m.ID, id, 200)
if msgs == nil {
msgs = []conversation.Message{}
}
writeJSON(w, http.StatusOK, ConversationView{Conversation: c, Messages: msgs})
}
// PostMessage records a user message as an observation event so the cognitive
// cycle perceives it. Shared with the UI. It returns the stored message.
func (s *Server) PostMessage(r *http.Request, mindID ids.ID, conv conversation.Conversation, text, who string) (conversation.Message, error) {
text = strings.TrimSpace(text)
if text == "" || len(text) > 20000 {
return conversation.Message{}, ValidationError("message required (max 20000 chars)")
}
trust := events.TrustUser
if p, ok := auth.PrincipalFrom(r.Context()); ok && p.User.Role.AtLeast(auth.RoleAdministrator) {
trust = events.TrustOperator
}
msg, err := s.deps.Conversations.AddMessage(r.Context(), s.deps.DB, conversation.Message{ConversationID: conv.ID, MindID: mindID, Role: "user", Actor: who, Content: text})
if err != nil {
return conversation.Message{}, convErr(err)
}
summary := text
if len(summary) > 160 {
summary = summary[:157] + "..."
}
evs, err := s.deps.Events.Append(r.Context(), events.New{MindID: mindID, Type: events.Observation, Source: "conversation", Summary: who + ": " + summary, Salience: 0.8, Confidence: 1,
Metadata: map[string]any{"conversation_id": string(conv.ID), "message_id": string(msg.ID), "trust": trust, "actor": who, "channel": conv.Channel, "person": conv.Person, "project": conv.Project},
Payload: events.ObservationPayload{Text: text, ContentType: "text/plain", Source: "conversation", Trust: trust, Method: "chat", Reference: string(msg.ID), Channel: string(conv.ID), Actor: who}})
if err != nil {
return msg, err
}
s.deps.Conversations.SetEventID(r.Context(), s.deps.DB, msg.ID, evs[0].ID) //nolint:errcheck
msg.EventID = evs[0].ID
return msg, nil
}
// WaitReply polls for the mind's reply to a message for up to d.
func (s *Server) WaitReply(r *http.Request, mindID, msgID ids.ID, d time.Duration) (conversation.Message, bool) {
deadline := time.Now().Add(d)
for {
if reply, err := s.deps.Conversations.Reply(r.Context(), mindID, msgID); err == nil {
return reply, true
} else if !errors.Is(err, storage.ErrNotFound) {
return conversation.Message{}, false
}
if time.Now().After(deadline) || r.Context().Err() != nil {
return conversation.Message{}, false
}
select {
case <-time.After(250 * time.Millisecond):
case <-r.Context().Done():
return conversation.Message{}, false
}
}
}
func (s *Server) messagePost(w http.ResponseWriter, r *http.Request) {
m, ok := s.mind(w, r)
if !ok {
return
}
id, err := ids.Parse(r.PathValue("id"))
if err != nil {
fail(w, r, ValidationError("bad id"))
return
}
conv, err := s.deps.Conversations.Get(r.Context(), m.ID, id)
if err != nil {
fail(w, r, err)
return
}
var in struct {
Text string `json:"text"`
Wait int `json:"wait_seconds"`
}
if err := decode(r, &in); err != nil {
fail(w, r, err)
return
}
msg, err := s.PostMessage(r, m.ID, conv, in.Text, actor(r))
if err != nil {
fail(w, r, err)
return
}
out := map[string]any{"message": msg}
if in.Wait > 0 {
if in.Wait > 120 {
in.Wait = 120
}
if reply, ok := s.WaitReply(r, m.ID, msg.ID, time.Duration(in.Wait)*time.Second); ok {
out["reply"] = reply
} else {
out["reply"] = nil
out["note"] = "no reply within the wait window; the mind may be paused, busy or over budget"
}
}
writeJSON(w, http.StatusCreated, out)
}
// ask is the one-shot conversational endpoint used by noemactl ask: it posts
// into (or creates) the caller's CLI conversation and waits for the reply.
func (s *Server) ask(w http.ResponseWriter, r *http.Request) {
m, ok := s.mind(w, r)
if !ok {
return
}
var in struct {
Text string `json:"text"`
ConversationID ids.ID `json:"conversation_id"`
Wait int `json:"wait_seconds"`
}
if err := decode(r, &in); err != nil {
fail(w, r, err)
return
}
var conv conversation.Conversation
var err error
if !in.ConversationID.IsZero() {
conv, err = s.deps.Conversations.Get(r.Context(), m.ID, in.ConversationID)
} else {
list, _ := s.deps.Conversations.List(r.Context(), m.ID, 50)
for _, c := range list {
if c.Channel == "cli" && c.Person == actor(r) {
conv = c
break
}
}
if conv.ID.IsZero() {
conv, err = s.deps.Conversations.Create(r.Context(), m.ID, "", "cli", actor(r), "")
}
}
if err != nil {
fail(w, r, err)
return
}
msg, err := s.PostMessage(r, m.ID, conv, in.Text, actor(r))
if err != nil {
fail(w, r, err)
return
}
if in.Wait <= 0 {
in.Wait = 30
}
if in.Wait > 120 {
in.Wait = 120
}
out := map[string]any{"conversation_id": conv.ID, "message": msg}
if reply, ok := s.WaitReply(r, m.ID, msg.ID, time.Duration(in.Wait)*time.Second); ok {
out["reply"] = reply
} else {
out["note"] = "no reply within " + strconv.Itoa(in.Wait) + "s"
}
writeJSON(w, http.StatusOK, out)
}
+104
View File
@@ -0,0 +1,104 @@
package api
import (
"encoding/json"
"errors"
"net/http"
"github.com/andydixon/noema/internal/auth"
"github.com/andydixon/noema/internal/beliefs"
"github.com/andydixon/noema/internal/memory"
"github.com/andydixon/noema/internal/minds"
"github.com/andydixon/noema/pkg/concepts"
"github.com/andydixon/noema/pkg/events"
)
// Seed is the mind-seed document (schema noema.mind-seed/1): identity,
// personality, configuration and starting knowledge. It is not the full
// export/import archive (phase 15); it is how sample and template minds are
// loaded. Treated as hostile input: strict decoding, size limits, operator
// trust on every item, no secrets, no policies.
type Seed struct {
Schema string `json:"schema"`
Slug string `json:"slug"`
DisplayName string `json:"display_name"`
Notes string `json:"notes"`
Personality *minds.Personality `json:"personality"`
Config *minds.CognitiveConfig `json:"config"`
Memories []struct {
Kind concepts.MemoryKind `json:"kind"`
Epistemic concepts.Epistemic `json:"epistemic"`
Content string `json:"content"`
Confidence float64 `json:"confidence"`
Importance float64 `json:"importance"`
} `json:"memories"`
Beliefs []struct {
Proposition string `json:"proposition"`
Subject string `json:"subject"`
Epistemic concepts.Epistemic `json:"epistemic"`
Weight float64 `json:"weight"`
Note string `json:"note"`
} `json:"beliefs"`
}
const maxSeedItems = 500
func (s *Server) importRoutes() {
s.mux.Handle("POST /api/v1/minds/import", require(auth.RoleAdministrator, s.mindImport))
}
// mindImport creates a mind from a seed document.
func (s *Server) mindImport(w http.ResponseWriter, r *http.Request) {
dec := json.NewDecoder(http.MaxBytesReader(w, r.Body, 4<<20))
dec.DisallowUnknownFields()
var seed Seed
if err := dec.Decode(&seed); err != nil {
fail(w, r, ValidationError("invalid seed: "+err.Error()))
return
}
if seed.Schema != "noema.mind-seed/1" {
fail(w, r, ValidationError("unsupported schema; expected noema.mind-seed/1"))
return
}
if len(seed.Memories) > maxSeedItems || len(seed.Beliefs) > maxSeedItems {
fail(w, r, ValidationError("too many seed items"))
return
}
m, err := s.deps.Minds.Create(r.Context(), minds.CreateParams{Slug: seed.Slug, DisplayName: seed.DisplayName, Personality: seed.Personality, Config: seed.Config, Notes: seed.Notes, CreatedBy: actor(r)})
if err != nil {
fail(w, r, mindErr(err))
return
}
var memCount, beliefCount int
var problems []string
for _, sm := range seed.Memories {
mem, err := s.deps.Memory.Create(r.Context(), s.deps.DB, memory.New{MindID: m.ID, Kind: sm.Kind, Epistemic: sm.Epistemic, Content: sm.Content, Source: "seed", Trust: events.TrustOperator, Confidence: sm.Confidence, Importance: sm.Importance, Salience: 0.5})
if err != nil {
problems = append(problems, "memory: "+err.Error())
continue
}
memCount++
s.deps.Events.Append(r.Context(), events.New{MindID: m.ID, Type: events.MemoryCreated, Source: "seed", Summary: "seeded " + string(mem.Kind) + " memory: " + trunc(mem.Content, 120), Salience: 0.3, Confidence: mem.Confidence, //nolint:errcheck
Evidence: []events.EvidenceRef{{Kind: "memory", ID: string(mem.ID)}}, Metadata: map[string]any{"memory_id": mem.ID, "kind": mem.Kind, "actor": actor(r)}})
}
for _, sb := range seed.Beliefs {
weight := sb.Weight
if weight <= 0 {
weight = 0.5
}
_, err := s.deps.Beliefs.Create(r.Context(), s.deps.DB, s.Emitter(), beliefs.New{MindID: m.ID, Proposition: sb.Proposition, Subject: sb.Subject, Epistemic: sb.Epistemic, Source: "seed",
Evidence: []beliefs.EvidenceInput{{Direction: "for", Kind: "operator", Source: "seed:" + actor(r), Weight: weight, Credibility: events.TrustOperator.Credibility(), Note: firstNonEmpty(sb.Note, "seeded by "+actor(r))}}})
if err != nil {
if errors.Is(err, beliefs.ErrInvalid) || errors.Is(err, beliefs.ErrBlocked) {
problems = append(problems, "belief: "+err.Error())
continue
}
fail(w, r, err)
return
}
beliefCount++
}
s.deps.Brain.Reconcile(r.Context()) //nolint:errcheck
s.deps.Audit.Log(r.Context(), "mind.import", "mind", m.ID.String(), map[string]any{"slug": m.Slug, "memories": memCount, "beliefs": beliefCount, "problems": problems})
writeJSON(w, http.StatusCreated, map[string]any{"mind": m, "memories": memCount, "beliefs": beliefCount, "problems": problems})
}
+3
View File
@@ -90,6 +90,9 @@ func (s *Server) mindsCreate(w http.ResponseWriter, r *http.Request) {
return return
} }
s.deps.Audit.Log(r.Context(), "mind.create", "mind", m.ID.String(), map[string]any{"slug": m.Slug}) s.deps.Audit.Log(r.Context(), "mind.create", "mind", m.ID.String(), map[string]any{"slug": m.Slug})
if s.deps.Brain != nil {
s.deps.Brain.Reconcile(r.Context()) //nolint:errcheck // starts the runtime now instead of at the next periodic reconcile
}
writeJSON(w, http.StatusCreated, m) writeJSON(w, http.StatusCreated, m)
} }
+2
View File
@@ -58,6 +58,8 @@ func (s *Server) routes() {
s.beliefRoutes() s.beliefRoutes()
s.llmRoutes() s.llmRoutes()
s.promptRoutes() s.promptRoutes()
s.conversationRoutes()
s.importRoutes()
m.HandleFunc("/api/", func(w http.ResponseWriter, r *http.Request) { m.HandleFunc("/api/", func(w http.ResponseWriter, r *http.Request) {
writeError(w, r, http.StatusNotFound, "not_found", "no such endpoint") writeError(w, r, http.StatusNotFound, "not_found", "no such endpoint")
+2
View File
@@ -15,6 +15,7 @@ import (
"github.com/andydixon/noema/internal/beliefs" "github.com/andydixon/noema/internal/beliefs"
"github.com/andydixon/noema/internal/brain" "github.com/andydixon/noema/internal/brain"
"github.com/andydixon/noema/internal/config" "github.com/andydixon/noema/internal/config"
"github.com/andydixon/noema/internal/conversation"
"github.com/andydixon/noema/internal/events" "github.com/andydixon/noema/internal/events"
"github.com/andydixon/noema/internal/llm/prompts" "github.com/andydixon/noema/internal/llm/prompts"
"github.com/andydixon/noema/internal/llm/router" "github.com/andydixon/noema/internal/llm/router"
@@ -37,6 +38,7 @@ type Deps struct {
Brain *brain.Manager Brain *brain.Manager
Memory *memory.Store Memory *memory.Store
Beliefs *beliefs.Store Beliefs *beliefs.Store
Conversations *conversation.Store
LLM *router.Router LLM *router.Router
LLMStore *router.Store LLMStore *router.Store
Prompts *prompts.Store Prompts *prompts.Store
+91
View File
@@ -787,3 +787,94 @@ func trunc(s string, n int) string {
} }
return s return s
} }
// Search returns live beliefs matching any of the words in text, best match
// first. Used to answer "what do you believe about X".
func (s *Store) Search(ctx context.Context, mind ids.ID, text string, limit int) ([]Belief, error) {
toks, _ := Polarity(text)
if len(toks) == 0 {
return nil, nil
}
if limit <= 0 || limit > 50 {
limit = 10
}
q := strings.Join(toks, " | ")
rows, err := s.db.Query(ctx, `SELECT `+cols+` FROM beliefs b WHERE b.mind_id=$1 AND b.status NOT IN ('superseded') AND b.search @@ to_tsquery('english', $2)
ORDER BY ts_rank_cd(b.search, to_tsquery('english', $2)) DESC, b.confidence DESC LIMIT $3`, mind, q, limit)
if err != nil {
return nil, err
}
defer rows.Close()
var out []Belief
for rows.Next() {
b, err := scan(rows)
if err != nil {
return nil, err
}
out = append(out, b)
}
return out, rows.Err()
}
// Change describes a belief whose status or confidence moved.
type Change struct {
Belief Belief `json:"belief"`
FromStatus Status `json:"from_status"`
FromConf float64 `json:"from_confidence"`
Reason string `json:"reason"`
At time.Time `json:"at"`
}
// RecentChanges lists beliefs whose latest version differs in status from an
// earlier version within the window ("what have you changed your mind about").
func (s *Store) RecentChanges(ctx context.Context, mind ids.ID, since time.Time, limit int) ([]Change, error) {
if limit <= 0 || limit > 50 {
limit = 10
}
rows, err := s.db.Query(ctx, `WITH latest AS (
SELECT DISTINCT ON (belief_id) belief_id, status, confidence, reason, created_at FROM belief_versions WHERE mind_id=$1 AND created_at >= $2 ORDER BY belief_id, version DESC),
first AS (
SELECT DISTINCT ON (belief_id) belief_id, status, confidence FROM belief_versions WHERE mind_id=$1 ORDER BY belief_id, version ASC)
SELECT `+cols+`, f.status, f.confidence, l.reason, l.created_at FROM latest l JOIN first f ON f.belief_id=l.belief_id JOIN beliefs b ON b.id=l.belief_id
WHERE l.status <> f.status ORDER BY l.created_at DESC LIMIT $3`, mind, since, limit)
if err != nil {
return nil, err
}
defer rows.Close()
var out []Change
for rows.Next() {
var c Change
var epi, status, fromStatus string
var detail []byte
var origin, superseded, pinned *string
b := &c.Belief
if err := rows.Scan(&b.ID, &b.MindID, &b.Proposition, &b.Subject, &epi, &status, &b.Confidence, &b.Prior, &b.Version, &b.Source, &detail, &origin, &superseded, &pinned, &b.FirstCreated, &b.LastEvaluated, &b.Evaluations, &fromStatus, &c.FromConf, &c.Reason, &c.At); err != nil {
return nil, err
}
b.Epistemic, b.Status, c.FromStatus = concepts.Epistemic(epi), Status(status), Status(fromStatus)
out = append(out, c)
}
return out, rows.Err()
}
// Uncertain returns beliefs in the middle band or contested, least certain first.
func (s *Store) Uncertain(ctx context.Context, mind ids.ID, limit int) ([]Belief, error) {
if limit <= 0 || limit > 50 {
limit = 10
}
rows, err := s.db.Query(ctx, `SELECT `+cols+` FROM beliefs b WHERE b.mind_id=$1 AND b.status NOT IN ('rejected','superseded') AND (b.status='contested' OR (b.confidence BETWEEN 0.3 AND 0.7))
ORDER BY abs(b.confidence-0.5) ASC, b.last_evaluated DESC LIMIT $2`, mind, limit)
if err != nil {
return nil, err
}
defer rows.Close()
var out []Belief
for rows.Next() {
b, err := scan(rows)
if err != nil {
return nil, err
}
out = append(out, b)
}
return out, rows.Err()
}
+3
View File
@@ -643,6 +643,9 @@ func (r *Runtime) observe(ctx context.Context, st *State) []Candidate {
return all return all
} }
// Workspace returns a copy of the live workspace state for handlers.
func (r *Runtime) Workspace() workspace.State { return r.copyState() }
// Events exposes the event store to handlers. // Events exposes the event store to handlers.
func (r *Runtime) Events() *evstore.Store { return r.events } func (r *Runtime) Events() *evstore.Store { return r.events }
+16
View File
@@ -6,6 +6,7 @@ package brain
import ( import (
"context" "context"
"sync/atomic"
"time" "time"
"github.com/andydixon/noema/internal/attention" "github.com/andydixon/noema/internal/attention"
@@ -65,6 +66,21 @@ type State struct {
NewEvents []events.Event // since the last cycle, ascending NewEvents []events.Event // since the last cycle, ascending
Workspace workspace.State // snapshot before this cycle Workspace workspace.State // snapshot before this cycle
Services Services Services Services
llmCalls atomic.Int32 // model calls made in this cycle; processes and handlers share the cap
}
// AllowLLM reserves one model call against the mind's per-cycle cap and
// reports whether it was granted, so a chatty cycle cannot exceed the budget.
func (s *State) AllowLLM() bool {
cap := int32(s.Mind.Config.LLMCallsPerCycle)
if cap <= 0 || s.Services.LLM == nil {
return false
}
if s.llmCalls.Add(1) > cap {
s.llmCalls.Add(-1)
return false
}
return true
} }
// Services are read handles processes may use. Extended per phase. // Services are read handles processes may use. Extended per phase.
+8
View File
@@ -9,6 +9,7 @@ import (
"github.com/andydixon/noema/internal/beliefs" "github.com/andydixon/noema/internal/beliefs"
"github.com/andydixon/noema/internal/brain" "github.com/andydixon/noema/internal/brain"
"github.com/andydixon/noema/internal/conversation"
"github.com/andydixon/noema/internal/memory" "github.com/andydixon/noema/internal/memory"
"github.com/andydixon/noema/internal/storage" "github.com/andydixon/noema/internal/storage"
"github.com/andydixon/noema/internal/workspace" "github.com/andydixon/noema/internal/workspace"
@@ -21,6 +22,7 @@ import (
type Handlers struct { type Handlers struct {
Memory *memory.Store Memory *memory.Store
Beliefs *beliefs.Store Beliefs *beliefs.Store
Conversations *conversation.Store
} }
// Register installs the handlers on a registry. // Register installs the handlers on a registry.
@@ -28,6 +30,7 @@ func (h Handlers) Register(r *brain.Registry) {
r.Handle("attend", h.attend) r.Handle("attend", h.attend)
r.Handle("retrieve", h.retrieve) r.Handle("retrieve", h.retrieve)
r.Handle("hypothesis", h.hypothesis) r.Handle("hypothesis", h.hypothesis)
r.Handle("perceive", h.perceive)
} }
// emitter adapts the cycle to the beliefs store's Emitter. // emitter adapts the cycle to the beliefs store's Emitter.
@@ -84,6 +87,9 @@ func (h Handlers) attend(ctx context.Context, c *brain.Cycle, t workspace.Though
var mem memory.Memory var mem memory.Memory
if h.Memory != nil && evID != "" { if h.Memory != nil && evID != "" {
ev, err := c.Runtime.Events().Get(ctx, ids.ID(evID)) ev, err := c.Runtime.Events().Get(ctx, ids.ID(evID))
if err == nil && ev.Metadata["conversation_id"] != nil {
err = errSkipEncode // the perceive handler encodes conversational episodes
}
if err == nil { if err == nil {
n := memory.New{MindID: c.State.Mind.ID, Kind: concepts.Episodic, Epistemic: concepts.Observation, Content: ev.Summary, Source: ev.Source, n := memory.New{MindID: c.State.Mind.ID, Kind: concepts.Episodic, Epistemic: concepts.Observation, Content: ev.Summary, Source: ev.Source,
Trust: events.TrustLow, Confidence: ev.Confidence, Importance: t.Score, Salience: ev.Salience, Valence: ev.Valence, Arousal: ev.Arousal, OccurredAt: ev.OccurredAt, OriginEvent: ev.ID, Trust: events.TrustLow, Confidence: ev.Confidence, Importance: t.Score, Salience: ev.Salience, Valence: ev.Valence, Arousal: ev.Arousal, OccurredAt: ev.OccurredAt, OriginEvent: ev.ID,
@@ -143,3 +149,5 @@ func (h Handlers) retrieve(ctx context.Context, c *brain.Cycle, t workspace.Thou
c.Runtime.Hold(workspace.Item{Key: "memory:" + ref, Content: t.Summary, Kind: "retrieval", Ref: ref, SourceEvent: ids.ID(src)}) c.Runtime.Hold(workspace.Item{Key: "memory:" + ref, Content: t.Summary, Kind: "retrieval", Ref: ref, SourceEvent: ids.ID(src)})
return nil return nil
} }
var errSkipEncode = errors.New("skip")
+111
View File
@@ -0,0 +1,111 @@
package cognition
import (
"context"
"encoding/json"
"strings"
"github.com/andydixon/noema/internal/attention"
"github.com/andydixon/noema/internal/brain"
"github.com/andydixon/noema/internal/conversation"
"github.com/andydixon/noema/internal/llm"
"github.com/andydixon/noema/pkg/events"
"github.com/andydixon/noema/pkg/ids"
)
// Perceive understands conversational messages. The deterministic analysis
// always runs; when a model is available and the cycle budget allows, the
// perceive prompt enriches entities and claims. Someone is waiting for an
// answer, so the candidate carries high urgency and operator priority.
type Perceive struct{}
// Name implements brain.Process.
func (Perceive) Name() string { return "perceive" }
// Observe implements brain.Process.
func (Perceive) Observe(ctx context.Context, s *brain.State) ([]brain.Candidate, error) {
var out []brain.Candidate
for _, ev := range s.NewEvents {
if ev.Type != events.Observation || ev.Metadata["conversation_id"] == nil {
continue
}
var p events.ObservationPayload
if json.Unmarshal(ev.Payload, &p) != nil || p.Text == "" {
continue
}
a := conversation.Analyse(p.Text)
content := map[string]any{"source_event": string(ev.ID), "conversation_id": ev.Metadata["conversation_id"], "message_id": ev.Metadata["message_id"], "text": p.Text, "actor": p.Actor, "trust": p.Trust}
evidence := []events.EvidenceRef{{Kind: "event", ID: string(ev.ID), Note: "message"}}
if s.Services.LLM != nil && s.Services.Prompts != nil && s.Services.LLM.Available(ctx, llm.RolePerceive) && s.AllowLLM() {
if pr, err := s.Services.Prompts.Get(ctx, "perceive.message"); err == nil {
if req, err := pr.Render(map[string]any{"Text": p.Text, "Actor": p.Actor, "Trust": p.Trust, "Channel": p.Channel}); err == nil {
req.MindID, req.CorrelationID, req.Purpose = s.Mind.ID, s.CycleID, "perceive message"
if resp, err := s.Services.LLM.Complete(ctx, req); err == nil && len(resp.JSON) > 0 {
merge(&a, resp.JSON)
content["llm_call"] = string(resp.CallID)
content["llm_model"] = resp.Provider + "/" + resp.Model
evidence = append(evidence, events.EvidenceRef{Kind: "llm_call", ID: string(resp.CallID), Note: "perception"})
}
}
}
}
content["analysis"] = a
prio := 0.7
if p.Trust == events.TrustOperator {
prio = 1
}
out = append(out, brain.Candidate{Kind: "perceive", Subject: string(ev.ID), Summary: "understand message (" + a.Intent + "): " + truncate(p.Text, 100), Content: content,
Features: attention.Features{Salience: 0.9, Urgency: 0.6 + 0.4*a.Urgency, Novelty: 0.5, Consequence: 0.8, Confidence: 1, OperatorPriority: prio, ContextRelevance: 0.6},
Confidence: 1, SourceEvents: []ids.ID{ev.ID}, Evidence: evidence})
}
return out, nil
}
// merge folds model-extracted fields into the deterministic analysis. The
// intent stays deterministic unless the model disagrees on question/statement
// and the message has no question mark; entities and claims are unioned.
func merge(a *conversation.Analysis, raw json.RawMessage) {
var m struct {
Intent string `json:"intent"`
Summary string `json:"summary"`
Entities []struct{ Name, Kind string } `json:"entities"`
Claims []struct{ Proposition, Certainty string } `json:"claims"`
Questions []string `json:"questions"`
Urgency float64 `json:"urgency"`
}
if json.Unmarshal(raw, &m) != nil {
return
}
a.Source = "llm"
seen := map[string]bool{}
for _, e := range a.Entities {
seen[strings.ToLower(e)] = true
}
for _, e := range m.Entities {
if e.Name != "" && !seen[strings.ToLower(e.Name)] {
seen[strings.ToLower(e.Name)] = true
a.Entities = append(a.Entities, e.Name)
}
}
if a.Intent == "statement" || a.Intent == "correction" {
known := map[string]bool{}
for _, c := range a.Claims {
known[strings.ToLower(c.Proposition)] = true
}
for _, c := range m.Claims {
if c.Proposition != "" && !known[strings.ToLower(c.Proposition)] && c.Certainty != "asked" {
cert := "asserted"
if c.Certainty == "hedged" {
cert = "hedged"
}
a.Claims = append(a.Claims, conversation.Claim{Proposition: c.Proposition, Certainty: cert})
}
}
}
if m.Urgency > a.Urgency {
a.Urgency = m.Urgency
}
if a.Intent == "statement" && m.Intent == "request" {
a.Intent = "request"
}
}
+433
View File
@@ -0,0 +1,433 @@
package cognition
import (
"context"
"encoding/json"
"fmt"
"strings"
"time"
"github.com/andydixon/noema/internal/beliefs"
"github.com/andydixon/noema/internal/brain"
"github.com/andydixon/noema/internal/conversation"
evstore "github.com/andydixon/noema/internal/events"
"github.com/andydixon/noema/internal/llm"
"github.com/andydixon/noema/internal/memory"
"github.com/andydixon/noema/internal/workspace"
"github.com/andydixon/noema/pkg/concepts"
"github.com/andydixon/noema/pkg/events"
"github.com/andydixon/noema/pkg/ids"
)
// Intent is the structured decision of what to say, built from state before
// any language is generated. It is stored with the reply so the operator can
// check that the words match the intent and the intent matches the evidence.
type Intent struct {
Kind string `json:"kind"` // answer | introspect | acknowledge | clarify | decline | greet | unknown
Summary string `json:"summary"`
Points []string `json:"points"`
Beliefs []BeliefRef `json:"beliefs"`
Memories []MemRef `json:"memories"`
Uncertainty string `json:"uncertainty,omitempty"`
Limitations []string `json:"limitations,omitempty"`
Analysis any `json:"analysis"`
}
// BeliefRef cites a belief.
type BeliefRef struct {
ID ids.ID `json:"id"`
Proposition string `json:"proposition"`
Confidence float64 `json:"confidence"`
Status string `json:"status"`
}
// MemRef cites a memory.
type MemRef struct {
ID ids.ID `json:"id"`
Kind string `json:"kind"`
Content string `json:"content"`
Confidence float64 `json:"confidence"`
}
// perceive is the handler for admitted "perceive" thoughts: it records the
// perception, encodes the episode, turns claims into beliefs, builds the
// response intent and generates the reply.
func (h Handlers) perceive(ctx context.Context, c *brain.Cycle, t workspace.Thought) error {
raw, _ := json.Marshal(t.Content["analysis"])
var a conversation.Analysis
json.Unmarshal(raw, &a) //nolint:errcheck
text, _ := t.Content["text"].(string)
actor, _ := t.Content["actor"].(string)
convID, _ := t.Content["conversation_id"].(string)
msgID, _ := t.Content["message_id"].(string)
srcID, _ := t.Content["source_event"].(string)
trust := events.Trust(fmt.Sprint(t.Content["trust"]))
if !trust.Valid() {
trust = events.TrustUser
}
mind := c.State.Mind
if call, ok := t.Content["llm_call"].(string); ok && call != "" {
c.Emit(events.New{Type: events.LLMResponse, Source: "brain.perceive", Summary: "perception enriched by " + fmt.Sprint(t.Content["llm_model"]), Salience: 0.1, Confidence: 0.8,
CausationID: t.EventID, Evidence: []events.EvidenceRef{{Kind: "llm_call", ID: call}}, Metadata: map[string]any{"role": "perceive", "call_id": call}})
}
perc, err := c.EmitNow(ctx, events.New{Type: events.Perception, Source: "brain.perceive", Summary: fmt.Sprintf("perceived %s from %s: %s", a.Intent, actor, truncate(text, 100)),
Salience: t.Score, Confidence: 0.9, CausationID: t.EventID, ParentIDs: []ids.ID{t.EventID, ids.ID(srcID)}, Evidence: t.Evidence,
Metadata: map[string]any{"intent": a.Intent, "introspection": a.Introspection, "conversation_id": convID, "message_id": msgID, "entities": a.Entities},
Payload: map[string]any{"analysis": a, "text": text, "actor": actor}})
if err != nil {
return err
}
// Episode of the exchange.
var memRef ids.ID
if h.Memory != nil {
mem, err := h.Memory.Create(ctx, c.Tx, memory.New{MindID: mind.ID, Kind: concepts.Episodic, Epistemic: concepts.Observation, Content: actor + " said: " + text, Source: "conversation",
Trust: trust, Confidence: 1, Importance: 0.4 + 0.3*a.Urgency, Salience: t.Score, OccurredAt: c.State.Now, OriginEvent: ids.ID(srcID),
Detail: map[string]any{"participants": []string{actor}, "channel": "conversation", "conversation_id": convID, "intent": a.Intent, "entities": a.Entities}})
if err == nil {
memRef = mem.ID
c.Emit(events.New{Type: events.MemoryCreated, Source: "brain.memory", Summary: "remembered: " + truncate(mem.Content, 100), Salience: 0.3, Confidence: 1, CausationID: perc.ID,
Evidence: []events.EvidenceRef{{Kind: "memory", ID: string(mem.ID)}, {Kind: "event", ID: srcID, Note: "source"}}, Metadata: map[string]any{"memory_id": mem.ID, "kind": mem.Kind}})
}
}
// Claims become beliefs; the message is their evidence.
var claimed []BeliefRef
if h.Beliefs != nil {
for _, cl := range a.Claims {
w := 0.6
if cl.Certainty == "hedged" {
w = 0.3
}
if a.Intent == "correction" {
w += 0.2
}
res, err := h.Beliefs.Create(ctx, c.Tx, emitter(c), beliefs.New{MindID: mind.ID, Proposition: cl.Proposition, Epistemic: concepts.Claim, Source: "conversation", OriginEvent: perc.ID, Causation: perc.ID, Correlation: c.State.CycleID,
Evidence: []beliefs.EvidenceInput{{Direction: "for", Kind: "event", RefID: srcID, Source: "said-by:" + actor, Weight: w, Credibility: trust.Credibility(), Note: actor + " " + cl.Certainty + " it"}}})
if err != nil {
c.Emit(events.New{Type: events.Metacognition, Source: "brain.perceive", Summary: "claim not recorded: " + err.Error(), Salience: 0.3, Confidence: 1, Metadata: map[string]any{"check": "claim_rejected"}})
continue
}
claimed = append(claimed, BeliefRef{ID: res.Belief.ID, Proposition: res.Belief.Proposition, Confidence: res.Belief.Confidence, Status: string(res.Belief.Status)})
}
}
intent := h.buildIntent(ctx, c, a, text, claimed)
reply, callID, err := h.generate(ctx, c, text, intent)
if err != nil {
return err
}
evidence := []events.EvidenceRef{{Kind: "event", ID: srcID, Note: "question"}, {Kind: "event", ID: string(perc.ID), Note: "perception"}}
for _, b := range intent.Beliefs {
evidence = append(evidence, events.EvidenceRef{Kind: "belief", ID: string(b.ID), Note: fmt.Sprintf("cited %.2f", b.Confidence)})
}
for _, m := range intent.Memories {
evidence = append(evidence, events.EvidenceRef{Kind: "memory", ID: string(m.ID), Note: "cited"})
}
if !memRef.IsZero() {
evidence = append(evidence, events.EvidenceRef{Kind: "memory", ID: string(memRef), Note: "episode"})
}
if !callID.IsZero() {
evidence = append(evidence, events.EvidenceRef{Kind: "llm_call", ID: string(callID), Note: "wording"})
}
respEv, err := c.EmitNow(ctx, events.New{Type: events.ResponseGenerated, Source: "brain.respond", Summary: "replied (" + intent.Kind + "): " + truncate(reply, 120), Salience: 0.5, Confidence: intentConfidence(intent),
CausationID: perc.ID, ParentIDs: []ids.ID{perc.ID}, Evidence: evidence,
Metadata: map[string]any{"conversation_id": convID, "message_id": msgID, "intent": intent.Kind, "llm_call": callID},
Payload: map[string]any{"intent": intent, "reply": reply, "model_used": !callID.IsZero()}})
if err != nil {
return err
}
if h.Conversations != nil && convID != "" {
intentJSON, _ := json.Marshal(intent)
if _, err := h.Conversations.AddMessage(ctx, c.Tx, conversation.Message{ConversationID: ids.ID(convID), MindID: mind.ID, Role: "mind", Actor: mind.DisplayName, Content: reply,
EventID: respEv.ID, ReplyTo: ids.ID(msgID), Intent: intentJSON, LLMCallID: callID}); err != nil {
return fmt.Errorf("store reply: %w", err)
}
}
c.Runtime.Hold(workspace.Item{Key: "conversation:" + convID, Content: actor + ": " + truncate(text, 80) + " → " + truncate(reply, 80), Kind: "thought", SourceEvent: ids.ID(srcID), Ref: convID})
return nil
}
func intentConfidence(in Intent) float64 {
var max float64
for _, b := range in.Beliefs {
if b.Confidence > max {
max = b.Confidence
}
}
switch in.Kind {
case "unknown", "decline":
return 0.9 // confident that it does not know
case "introspect", "acknowledge", "greet":
return 0.95
}
if max == 0 {
return 0.5
}
return max
}
// buildIntent decides what to convey from state alone.
func (h Handlers) buildIntent(ctx context.Context, c *brain.Cycle, a conversation.Analysis, text string, claimed []BeliefRef) Intent {
mind := c.State.Mind
in := Intent{Analysis: a, Limitations: c.State.SelfModel.Limitations}
if len(in.Limitations) > 3 {
in.Limitations = in.Limitations[:3]
}
topic := a.Topic
if topic == "" {
topic = text
}
switch a.Intent {
case "greeting":
in.Kind, in.Summary = "greet", "Greet "+firstNonEmpty(a.Topic, "the user")+" briefly and offer to help."
case "statement", "correction":
in.Kind = "acknowledge"
if len(claimed) == 0 {
in.Summary = "Acknowledge the message; no checkable claim was found in it."
break
}
in.Beliefs = claimed
in.Summary = fmt.Sprintf("Acknowledge that %d statement(s) were recorded as claims with the shown confidence, noting they rest on this message alone.", len(claimed))
for _, b := range claimed {
in.Points = append(in.Points, fmt.Sprintf("Recorded \"%s\" as a %s at confidence %.2f.", b.Proposition, b.Status, b.Confidence))
if b.Status == string(beliefs.StatusContested) {
in.Points = append(in.Points, "That conflicts with something I already held; both are now contested.")
}
}
case "request":
in.Kind = "decline"
caps := c.State.SelfModel.Capabilities
if len(caps) == 0 {
in.Summary = "Explain honestly that no external capabilities are granted, so the request cannot be carried out; offer what is known instead."
in.Points = append(in.Points, "I have no capabilities granted to act on external systems.")
} else {
in.Summary = "Explain which capabilities exist and that actions require policy approval."
}
h.addKnowledge(ctx, c, &in, topic)
case "introspection":
in.Kind = "introspect"
h.introspect(ctx, c, &in, a, topic)
default: // question, other
in.Kind = "answer"
h.addKnowledge(ctx, c, &in, topic)
if len(in.Beliefs) == 0 && len(in.Memories) == 0 {
in.Kind = "unknown"
in.Summary = "Say plainly that nothing is known about this; do not guess."
in.Uncertainty = "No beliefs or memories match the question."
} else {
in.Summary = "Answer from the cited beliefs and memories, stating confidence; say what is uncertain."
}
}
if in.Points == nil {
in.Points = []string{}
}
if in.Beliefs == nil {
in.Beliefs = []BeliefRef{}
}
if in.Memories == nil {
in.Memories = []MemRef{}
}
_ = mind
return in
}
// addKnowledge cites the beliefs and memories relevant to a topic.
func (h Handlers) addKnowledge(ctx context.Context, c *brain.Cycle, in *Intent, topic string) {
mind := c.State.Mind.ID
if h.Beliefs != nil {
if bs, _ := h.Beliefs.Search(ctx, mind, topic, 5); len(bs) > 0 {
var maxConf float64
for _, b := range bs {
if b.Status == beliefs.StatusRejected {
continue
}
in.Beliefs = append(in.Beliefs, BeliefRef{ID: b.ID, Proposition: b.Proposition, Confidence: b.Confidence, Status: string(b.Status)})
in.Points = append(in.Points, fmt.Sprintf("%s (%s, %.2f)", b.Proposition, b.Status, b.Confidence))
if b.Confidence > maxConf {
maxConf = b.Confidence
}
}
if maxConf > 0 && maxConf < 0.6 {
in.Uncertainty = fmt.Sprintf("The strongest relevant belief is only at %.2f.", maxConf)
}
}
}
if h.Memory != nil {
if hits, _ := h.Memory.Retrieve(ctx, mind, memory.Query{Text: topic, Limit: 4, Now: c.State.Now, Semantic: true}); len(hits) > 0 {
for _, r := range hits {
if r.Score < 0.3 {
continue
}
in.Memories = append(in.Memories, MemRef{ID: r.Memory.ID, Kind: string(r.Memory.Kind), Content: truncate(r.Memory.Content, 200), Confidence: r.Memory.Confidence})
in.Points = append(in.Points, fmt.Sprintf("I remember: %s (%s, %.2f)", truncate(r.Memory.Content, 160), r.Memory.Kind, r.Memory.Confidence))
}
}
}
}
// introspect answers questions about the mind's own state from that state.
func (h Handlers) introspect(ctx context.Context, c *brain.Cycle, in *Intent, a conversation.Analysis, topic string) {
mind := c.State.Mind.ID
ws := c.Runtime.Workspace()
switch a.Introspection {
case "thinking":
in.Summary = "Describe what is currently in the workspace and working memory, most salient first."
for i, t := range ws.Workspace {
if i >= 5 {
break
}
in.Points = append(in.Points, fmt.Sprintf("%s (score %.2f)", t.Summary, t.Score))
}
for i, it := range ws.WorkingMemory {
if i >= 4 {
break
}
in.Points = append(in.Points, "holding: "+truncate(it.Content, 100))
}
if len(in.Points) == 0 {
in.Points = append(in.Points, "The workspace is empty; nothing is being attended to right now.")
}
case "beliefs":
in.Summary = "List what is believed about the topic with confidence and status; say if nothing is known."
h.addKnowledge(ctx, c, in, topic)
if len(in.Beliefs) == 0 && len(in.Memories) == 0 {
in.Kind, in.Uncertainty = "unknown", "No beliefs or memories concern "+topic+"."
}
case "why":
in.Summary = "Explain the evidence for and against the belief the user asked about, and what would change it."
if h.Beliefs != nil {
if bs, _ := h.Beliefs.Search(ctx, mind, topic, 1); len(bs) > 0 {
b := bs[0]
in.Beliefs = append(in.Beliefs, BeliefRef{ID: b.ID, Proposition: b.Proposition, Confidence: b.Confidence, Status: string(b.Status)})
rows, _ := h.Beliefs.EvidenceFor(ctx, c.Tx, mind, b.ID)
for _, e := range rows {
if e.Retracted {
continue
}
in.Points = append(in.Points, fmt.Sprintf("%s: %s (weight %.2f × credibility %.2f)", e.Direction, firstNonEmpty(e.Note, e.Kind), e.Weight, e.Credibility))
}
in.Points = append(in.Points, "Independent evidence from another source would move it most; a contradicting observation would lower it.")
} else {
in.Kind, in.Summary = "unknown", "Say that no belief matches what was asked."
}
}
case "uncertain":
in.Summary = "List the beliefs that are least settled, with confidence."
if h.Beliefs != nil {
bs, _ := h.Beliefs.Uncertain(ctx, mind, 6)
for _, b := range bs {
in.Beliefs = append(in.Beliefs, BeliefRef{ID: b.ID, Proposition: b.Proposition, Confidence: b.Confidence, Status: string(b.Status)})
in.Points = append(in.Points, fmt.Sprintf("%s (%s, %.2f)", b.Proposition, b.Status, b.Confidence))
}
}
if len(in.Points) == 0 {
in.Points = append(in.Points, "No beliefs sit in the uncertain band right now.")
}
case "contradictions":
in.Summary = "List contested beliefs and what they conflict with."
if h.Beliefs != nil {
bs, _ := h.Beliefs.Contested(ctx, mind, 6)
for _, b := range bs {
in.Beliefs = append(in.Beliefs, BeliefRef{ID: b.ID, Proposition: b.Proposition, Confidence: b.Confidence, Status: string(b.Status)})
links, _ := h.Beliefs.Links(ctx, mind, b.ID)
for _, l := range links {
if l.Relation == "contradicts" && l.Other != nil {
in.Points = append(in.Points, fmt.Sprintf("\"%s\" (%.2f) contradicts \"%s\" (%.2f)", b.Proposition, b.Confidence, l.Other.Proposition, l.Other.Confidence))
}
}
}
}
if len(in.Points) == 0 {
in.Points = append(in.Points, "No contradictions are currently open.")
}
case "learned":
in.Summary = "Summarise what was recorded in the last seven days: new memories and beliefs."
evs, _ := c.Runtime.Events().Query(ctx, evstore.Filter{MindID: mind, Types: []events.Type{events.MemoryCreated, events.BeliefCreated, events.HypothesisCreated, events.MemoryConsolidated}, Since: c.State.Now.Add(-7 * 24 * time.Hour), Limit: 8})
for _, e := range evs {
in.Points = append(in.Points, e.Summary)
}
if len(in.Points) == 0 {
in.Points = append(in.Points, "Nothing new was recorded this week.")
}
case "changed":
in.Summary = "List beliefs whose status changed recently and why."
if h.Beliefs != nil {
ch, _ := h.Beliefs.RecentChanges(ctx, mind, c.State.Now.Add(-30*24*time.Hour), 6)
for _, x := range ch {
in.Beliefs = append(in.Beliefs, BeliefRef{ID: x.Belief.ID, Proposition: x.Belief.Proposition, Confidence: x.Belief.Confidence, Status: string(x.Belief.Status)})
in.Points = append(in.Points, fmt.Sprintf("\"%s\": %s (%.2f) → %s (%.2f) because %s", x.Belief.Proposition, x.FromStatus, x.FromConf, x.Belief.Status, x.Belief.Confidence, firstNonEmpty(x.Reason, "evidence changed")))
}
}
if len(in.Points) == 0 {
in.Points = append(in.Points, "No belief has changed status in the last month.")
}
default: // predictions, curious, surprised, patterns: subsystems not yet present
in.Kind = "decline"
in.Summary = "Explain that this aspect of self-observation is not available in this version, without inventing anything."
in.Points = append(in.Points, "I do not yet keep "+a.Introspection+" records, so I cannot report on them honestly.")
}
}
// generate phrases the intent. With a model: the respond prompt, constrained
// to cited material. Without: a deterministic rendering of the points.
func (h Handlers) generate(ctx context.Context, c *brain.Cycle, text string, in Intent) (string, ids.ID, error) {
s := c.State
if s.Services.LLM != nil && s.Services.Prompts != nil && s.Services.LLM.Available(ctx, llm.RoleRespond) && s.AllowLLM() {
if pr, err := s.Services.Prompts.Get(ctx, "respond"); err == nil {
data := map[string]any{"Brief": s.Mind.Personality.Brief(), "Intent": in.Summary + "\n" + strings.Join(in.Points, "\n"), "Beliefs": in.Beliefs, "Memories": in.Memories, "Uncertainty": in.Uncertainty, "Limitations": in.Limitations, "Message": text}
if req, err := pr.Render(data); err == nil {
req.MindID, req.CorrelationID, req.Purpose = s.Mind.ID, s.CycleID, "respond"
c.Emit(events.New{Type: events.LLMRequest, Source: "brain.respond", Summary: "asked model to phrase reply (" + in.Kind + ")", Salience: 0.1, Confidence: 1, Metadata: map[string]any{"role": "respond", "prompt": pr.Name, "version": pr.CurrentVersion}})
resp, err := s.Services.LLM.Complete(ctx, req)
if err == nil && strings.TrimSpace(resp.Text) != "" {
c.Emit(events.New{Type: events.LLMResponse, Source: "brain.respond", Summary: "model phrased reply via " + resp.Provider + "/" + resp.Model, Salience: 0.1, Confidence: 1,
Evidence: []events.EvidenceRef{{Kind: "llm_call", ID: string(resp.CallID)}}, Metadata: map[string]any{"role": "respond", "call_id": string(resp.CallID), "tokens": resp.Usage.TotalTokens}})
return strings.TrimSpace(resp.Text), resp.CallID, nil
}
c.Emit(events.New{Type: events.Metacognition, Source: "brain.respond", Summary: "model reply failed; using deterministic wording", Salience: 0.3, Confidence: 1, Metadata: map[string]any{"check": "llm_failure", "error": errString(err)}})
}
}
}
return deterministicReply(in), ids.Nil, nil
}
func errString(err error) string {
if err == nil {
return "empty response"
}
return err.Error()
}
// deterministicReply renders the intent without a model: honest, plain, with
// confidences visible.
func deterministicReply(in Intent) string {
var b strings.Builder
switch in.Kind {
case "greet":
b.WriteString("Hello. I can tell you what I currently hold and how sure I am of it.")
case "unknown":
b.WriteString("I don't know. Nothing I hold bears on that.")
case "decline":
b.WriteString("I can't do that.")
case "acknowledge":
b.WriteString("Noted.")
case "introspect":
b.WriteString("From my current state:")
default:
b.WriteString("Here is what I hold on that:")
}
for _, p := range in.Points {
b.WriteString("\n- " + p)
}
if in.Uncertainty != "" {
b.WriteString("\n" + in.Uncertainty)
}
b.WriteString("\n(No language model is configured, so this is a direct rendering of my state.)")
return b.String()
}
func firstNonEmpty(a, b string) string {
if a != "" {
return a
}
return b
}
+224
View File
@@ -0,0 +1,224 @@
package conversation
import (
"regexp"
"sort"
"strings"
)
// Analysis is the deterministic understanding of a message. An LLM may
// enrich it (entities, claims) but these fields are always computed first so
// the system works without a model and never depends on one for routing.
type Analysis struct {
Intent string `json:"intent"` // question | statement | request | correction | greeting | introspection | other
Introspection string `json:"introspection"` // thinking | beliefs | why | uncertain | contradictions | learned | changed | curious | predictions | surprised | patterns | ""
Topic string `json:"topic,omitempty"`
Entities []string `json:"entities"`
Tokens []string `json:"tokens"`
Urgency float64 `json:"urgency"`
Sentiment float64 `json:"sentiment"`
Claims []Claim `json:"claims"`
Question string `json:"question,omitempty"`
Source string `json:"source"` // deterministic | llm
}
// Claim is a proposition the sender asserted.
type Claim struct {
Proposition string `json:"proposition"`
Certainty string `json:"certainty"` // asserted | hedged
}
var (
whWords = map[string]bool{"what": true, "why": true, "how": true, "when": true, "where": true, "who": true, "which": true, "is": true, "are": true, "do": true, "does": true, "did": true, "can": true, "could": true, "should": true, "will": true, "was": true, "were": true, "have": true, "has": true}
greetings = map[string]bool{"hi": true, "hello": true, "hey": true, "morning": true, "evening": true, "thanks": true, "thank": true, "bye": true, "goodbye": true}
hedges = []string{"i think", "maybe", "perhaps", "possibly", "might", "probably", "not sure", "i guess", "seems"}
requests = []string{"please", "can you", "could you", "would you", "check", "look into", "investigate", "find out", "tell me", "show me", "list", "explain", "summarise", "summarize"}
corrections = []string{"that's wrong", "that is wrong", "thats wrong", "you're wrong", "incorrect", "not true", "actually,", "no,", "wrong,", "that's not right", "that is not right"}
urgentWords = map[string]float64{"urgent": 0.9, "asap": 0.9, "immediately": 0.9, "now": 0.5, "down": 0.8, "outage": 0.9, "broken": 0.7, "failed": 0.7, "failing": 0.7, "error": 0.6, "errors": 0.6, "critical": 0.9, "emergency": 1}
positive = map[string]bool{"good": true, "great": true, "thanks": true, "excellent": true, "love": true, "nice": true, "works": true, "fixed": true, "happy": true}
negative = map[string]bool{"bad": true, "broken": true, "hate": true, "terrible": true, "annoying": true, "slow": true, "failed": true, "wrong": true, "frustrated": true, "angry": true, "down": true, "error": true, "errors": true, "outage": true}
entityRe = regexp.MustCompile(`\b([A-Z][a-zA-Z0-9]+(?:[- ][A-Z][a-zA-Z0-9]+)*|[a-z][a-z0-9]*(?:-[a-z0-9]+)+|[a-z0-9.-]+\.(?:com|net|org|io|local|lan)|[a-z]+[0-9]{1,3})\b`)
introspect = []struct {
kind string
pats []string
}{
{"thinking", []string{"what are you thinking", "thinking about", "on your mind", "what are you doing", "what are you working on", "current focus"}},
{"why", []string{"why do you believe", "why do you think", "what evidence", "how do you know", "what makes you think", "would change your mind"}},
{"uncertain", []string{"uncertain about", "unsure about", "not sure about", "what don't you know", "what do you not know", "confidence in"}},
{"contradictions", []string{"contradict", "conflict with each other", "inconsistent"}},
{"learned", []string{"what did you learn", "what have you learned", "learned this week", "learned today", "learnt"}},
{"changed", []string{"changed your mind", "change your mind about", "used to believe", "no longer believe"}},
{"curious", []string{"curious about", "what interests you", "want to know more", "what would you like to investigate"}},
{"predictions", []string{"predict", "what do you expect", "what will happen", "forecast"}},
{"surprised", []string{"surprise", "unexpected", "surprising"}},
{"patterns", []string{"pattern", "noticed", "what have you seen", "recurring"}},
{"beliefs", []string{"what do you believe", "what do you know about", "what do you think about", "tell me about", "what do you remember about", "do you know", "beliefs about", "believe about"}},
}
)
// Analyse performs the deterministic pass.
func Analyse(text string) Analysis {
a := Analysis{Source: "deterministic", Entities: []string{}, Claims: []Claim{}}
lower := strings.ToLower(strings.TrimSpace(text))
a.Tokens = tokens(lower)
words := strings.Fields(lower)
if len(words) == 0 {
a.Intent = "other"
return a
}
// Urgency and sentiment from lexicons.
for _, w := range a.Tokens {
if u, ok := urgentWords[w]; ok && u > a.Urgency {
a.Urgency = u
}
if positive[w] {
a.Sentiment += 0.3
}
if negative[w] {
a.Sentiment -= 0.3
}
}
if a.Sentiment > 1 {
a.Sentiment = 1
}
if a.Sentiment < -1 {
a.Sentiment = -1
}
// Entities: hostnames, hyphenated ids, Capitalised names.
seen := map[string]bool{}
for _, m := range entityRe.FindAllString(text, -1) {
if len(m) < 3 || whWords[strings.ToLower(m)] || greetings[strings.ToLower(m)] {
continue
}
// Skip a sentence-initial capitalised common word.
if strings.HasPrefix(text, m) && strings.ToLower(m) == m[:1]+strings.ToLower(m[1:]) && !strings.ContainsAny(m, "-0123456789.") && len(a.Tokens) > 0 && a.Tokens[0] == strings.ToLower(m) {
continue
}
key := strings.ToLower(m)
if !seen[key] {
seen[key] = true
a.Entities = append(a.Entities, m)
}
}
// Intent.
isQuestion := strings.HasSuffix(lower, "?") || whWords[words[0]] && len(words) > 2
for _, c := range corrections {
if strings.Contains(lower, c) {
a.Intent = "correction"
break
}
}
if a.Intent == "" {
for _, in := range introspect {
for _, p := range in.pats {
if strings.Contains(lower, p) {
a.Intent, a.Introspection = "introspection", in.kind
break
}
}
if a.Intent != "" {
break
}
}
}
if a.Intent == "" && len(words) <= 3 && greetings[strings.Trim(words[0], "!.,")] {
a.Intent = "greeting"
}
if a.Intent == "" && isQuestion {
a.Intent = "question"
}
if a.Intent == "" {
for _, r := range requests {
if strings.Contains(lower, r) {
a.Intent = "request"
break
}
}
}
if a.Intent == "" {
a.Intent = "statement"
}
if a.Intent == "question" || a.Intent == "introspection" {
a.Question = strings.TrimSpace(text)
}
// Topic: the entity mentioned after "about", else the first entity.
if i := strings.Index(lower, " about "); i >= 0 {
rest := strings.Trim(strings.TrimSpace(lower[i+7:]), "?.!")
if rest != "" {
a.Topic = strings.Join(strings.Fields(rest), " ")
}
}
if a.Topic == "" && len(a.Entities) > 0 {
a.Topic = a.Entities[0]
}
// Claims: declarative sentences in statements and corrections.
if a.Intent == "statement" || a.Intent == "correction" {
for _, sent := range splitSentences(text) {
sl := strings.ToLower(sent)
if strings.HasSuffix(strings.TrimSpace(sent), "?") || len(strings.Fields(sent)) < 3 {
continue
}
certainty := "asserted"
for _, h := range hedges {
if strings.Contains(sl, h) {
certainty = "hedged"
break
}
}
clean := strings.TrimSpace(sent)
for _, c := range corrections {
clean = strings.TrimSpace(strings.TrimPrefix(strings.ToLower(clean), c))
}
if len(clean) >= 8 {
a.Claims = append(a.Claims, Claim{Proposition: strings.TrimRight(clean, ".!"), Certainty: certainty})
}
}
}
return a
}
func splitSentences(text string) []string {
var out []string
var cur strings.Builder
runes := []rune(text)
for i, r := range runes {
cur.WriteRune(r)
endOfSentence := r == '\n' || ((r == '.' || r == '!' || r == '?') && (i+1 == len(runes) || runes[i+1] == ' ' || runes[i+1] == '\n'))
if endOfSentence {
if s := strings.TrimSpace(cur.String()); s != "" {
out = append(out, s)
}
cur.Reset()
}
}
if s := strings.TrimSpace(cur.String()); s != "" {
out = append(out, s)
}
return out
}
var stop = map[string]bool{"the": true, "a": true, "an": true, "is": true, "are": true, "was": true, "were": true, "of": true, "to": true, "in": true, "on": true, "at": true, "for": true, "and": true, "or": true, "that": true, "this": true, "it": true, "you": true, "your": true, "i": true, "me": true, "my": true, "we": true, "do": true, "does": true, "about": true, "what": true, "why": true, "how": true, "with": true, "be": true, "have": true, "has": true, "had": true, "can": true, "please": true}
func tokens(lower string) []string {
var out []string
seen := map[string]bool{}
var cur strings.Builder
flush := func() {
w := strings.Trim(cur.String(), ".-_")
cur.Reset()
if len(w) >= 2 && !stop[w] && !seen[w] {
seen[w] = true
out = append(out, w)
}
}
for _, r := range lower {
if r >= 'a' && r <= 'z' || r >= '0' && r <= '9' || r == '-' || r == '.' || r == '_' {
cur.WriteRune(r)
} else {
flush()
}
}
flush()
sort.Strings(out)
return out
}
+44
View File
@@ -0,0 +1,44 @@
package conversation
import "testing"
func TestAnalyseIntents(t *testing.T) {
cases := []struct {
text, intent, intro string
}{
{"What are you thinking about?", "introspection", "thinking"},
{"Why do you believe atlas is slow?", "introspection", "why"},
{"What are you uncertain about?", "introspection", "uncertain"},
{"Which of your beliefs contradict each other?", "introspection", "contradictions"},
{"What did you learn this week?", "introspection", "learned"},
{"What have you changed your mind about?", "introspection", "changed"},
{"What do you currently believe about atlas?", "introspection", "beliefs"},
{"Is atlas slow again since the deploy?", "question", ""},
{"Please check the disk on server-01", "request", ""},
{"No, that's wrong, atlas is on the new rack", "correction", ""},
{"hello", "greeting", ""},
{"Andy prefers Go for deployed services.", "statement", ""},
}
for _, c := range cases {
a := Analyse(c.text)
if a.Intent != c.intent || a.Introspection != c.intro {
t.Errorf("%q: got %s/%s want %s/%s", c.text, a.Intent, a.Introspection, c.intent, c.intro)
}
}
}
func TestAnalyseEntitiesClaimsAndUrgency(t *testing.T) {
a := Analyse("server-01 is down and atlas.local shows SMART errors. I think the SATA cable might be loose.")
if a.Urgency < 0.8 || a.Sentiment >= 0 {
t.Fatalf("urgency/sentiment wrong: %+v", a)
}
if len(a.Entities) < 2 {
t.Fatalf("entities missing: %v", a.Entities)
}
if len(a.Claims) != 2 || a.Claims[0].Certainty != "asserted" || a.Claims[1].Certainty != "hedged" {
t.Fatalf("claims wrong: %+v", a.Claims)
}
if b := Analyse("What do you believe about server-01?"); b.Topic != "server-01" {
t.Fatalf("topic wrong: %q", b.Topic)
}
}
+215
View File
@@ -0,0 +1,215 @@
// Package conversation stores conversations and messages and provides the
// deterministic first pass of message understanding. A user message is never
// sent straight to a model: it becomes an observation event that the
// cognitive cycle perceives, recalls against, reasons about and answers.
package conversation
import (
"context"
"encoding/json"
"errors"
"fmt"
"strings"
"time"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/andydixon/noema/internal/storage"
"github.com/andydixon/noema/pkg/ids"
)
// Conversation groups messages with a person, project and channel.
type Conversation struct {
ID ids.ID `json:"id"`
MindID ids.ID `json:"mind_id"`
Title string `json:"title"`
Channel string `json:"channel"`
Person string `json:"person"`
Project string `json:"project"`
Context map[string]any `json:"context"`
CreatedAt time.Time `json:"created_at"`
LastMessageAt time.Time `json:"last_message_at"`
}
// Message is one turn.
type Message struct {
ID ids.ID `json:"id"`
ConversationID ids.ID `json:"conversation_id"`
MindID ids.ID `json:"mind_id"`
Role string `json:"role"` // user | mind | system
Actor string `json:"actor"`
Content string `json:"content"`
EventID ids.ID `json:"event_id,omitempty"`
ReplyTo ids.ID `json:"reply_to,omitempty"`
Intent json.RawMessage `json:"intent,omitempty"`
LLMCallID ids.ID `json:"llm_call_id,omitempty"`
CreatedAt time.Time `json:"created_at"`
}
// ErrInvalid marks bad input.
var ErrInvalid = errors.New("conversation: invalid")
// Store persists conversations.
type Store struct{ db *pgxpool.Pool }
// NewStore creates a store.
func NewStore(db *pgxpool.Pool) *Store { return &Store{db: db} }
// Create starts a conversation.
func (s *Store) Create(ctx context.Context, mind ids.ID, title, channel, person, project string) (Conversation, error) {
if channel == "" {
channel = "web"
}
c := Conversation{ID: ids.New(), MindID: mind, Title: strings.TrimSpace(title), Channel: channel, Person: strings.TrimSpace(person), Project: strings.TrimSpace(project), Context: map[string]any{}, CreatedAt: time.Now(), LastMessageAt: time.Now()}
_, err := s.db.Exec(ctx, `INSERT INTO conversations (id, mind_id, title, channel, person, project) VALUES ($1,$2,$3,$4,$5,$6)`, c.ID, c.MindID, c.Title, c.Channel, c.Person, c.Project)
return c, err
}
const convCols = `id, mind_id, title, channel, person, project, context, created_at, last_message_at`
func scanConv(row pgx.Row) (Conversation, error) {
var c Conversation
var ctxJSON []byte
if err := row.Scan(&c.ID, &c.MindID, &c.Title, &c.Channel, &c.Person, &c.Project, &ctxJSON, &c.CreatedAt, &c.LastMessageAt); err != nil {
return c, storage.Translate(err)
}
json.Unmarshal(ctxJSON, &c.Context) //nolint:errcheck
if c.Context == nil {
c.Context = map[string]any{}
}
return c, nil
}
// Get fetches a conversation scoped to a mind.
func (s *Store) Get(ctx context.Context, mind, id ids.ID) (Conversation, error) {
return scanConv(s.db.QueryRow(ctx, `SELECT `+convCols+` FROM conversations WHERE id=$1 AND mind_id=$2`, id, mind))
}
// List returns a mind's conversations, most recent first.
func (s *Store) List(ctx context.Context, mind ids.ID, limit int) ([]Conversation, error) {
if limit <= 0 || limit > 200 {
limit = 50
}
rows, err := s.db.Query(ctx, `SELECT `+convCols+` FROM conversations WHERE mind_id=$1 ORDER BY last_message_at DESC LIMIT $2`, mind, limit)
if err != nil {
return nil, err
}
defer rows.Close()
var out []Conversation
for rows.Next() {
c, err := scanConv(rows)
if err != nil {
return nil, err
}
out = append(out, c)
}
return out, rows.Err()
}
// AddMessage appends a message using q (pool or transaction).
func (s *Store) AddMessage(ctx context.Context, q storage.Querier, m Message) (Message, error) {
m.Content = strings.TrimSpace(m.Content)
if m.Content == "" || len(m.Content) > 20000 {
return Message{}, fmt.Errorf("%w: content required (max 20000 chars)", ErrInvalid)
}
if m.Role != "user" && m.Role != "mind" && m.Role != "system" {
return Message{}, fmt.Errorf("%w: bad role", ErrInvalid)
}
if m.ID.IsZero() {
m.ID = ids.New()
}
m.CreatedAt = time.Now()
_, err := q.Exec(ctx, `INSERT INTO messages (id, conversation_id, mind_id, role, actor, content, event_id, reply_to, intent, llm_call_id, created_at)
SELECT $1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11 WHERE EXISTS (SELECT 1 FROM conversations WHERE id=$2 AND mind_id=$3)`,
m.ID, m.ConversationID, m.MindID, m.Role, m.Actor, m.Content, nullID(m.EventID), nullID(m.ReplyTo), nullRaw(m.Intent), nullID(m.LLMCallID), m.CreatedAt)
if err != nil {
return Message{}, err
}
q.Exec(ctx, `UPDATE conversations SET last_message_at=$2, title=CASE WHEN title='' THEN left($3, 80) ELSE title END WHERE id=$1`, m.ConversationID, m.CreatedAt, m.Content) //nolint:errcheck
return m, nil
}
// SetEventID links a message to its event after the event is written.
func (s *Store) SetEventID(ctx context.Context, q storage.Querier, id, eventID ids.ID) error {
_, err := q.Exec(ctx, `UPDATE messages SET event_id=$2 WHERE id=$1`, id, eventID)
return err
}
func nullID(id ids.ID) *string {
if id.IsZero() {
return nil
}
s := string(id)
return &s
}
func nullRaw(r json.RawMessage) *json.RawMessage {
if len(r) == 0 {
return nil
}
return &r
}
// Messages lists a conversation's messages in order.
func (s *Store) Messages(ctx context.Context, mind, conv ids.ID, limit int) ([]Message, error) {
if limit <= 0 || limit > 1000 {
limit = 200
}
rows, err := s.db.Query(ctx, `SELECT id, conversation_id, mind_id, role, actor, content, event_id, reply_to, intent, llm_call_id, created_at FROM messages
WHERE conversation_id=$1 AND mind_id=$2 ORDER BY created_at DESC LIMIT $3`, conv, mind, limit)
if err != nil {
return nil, err
}
defer rows.Close()
var out []Message
for rows.Next() {
m, err := scanMessage(rows)
if err != nil {
return nil, err
}
out = append(out, m)
}
for i, j := 0, len(out)-1; i < j; i, j = i+1, j-1 {
out[i], out[j] = out[j], out[i]
}
return out, rows.Err()
}
func scanMessage(row pgx.Row) (Message, error) {
var m Message
var ev, reply, call *string
var intent *json.RawMessage
if err := row.Scan(&m.ID, &m.ConversationID, &m.MindID, &m.Role, &m.Actor, &m.Content, &ev, &reply, &intent, &call, &m.CreatedAt); err != nil {
return m, storage.Translate(err)
}
if ev != nil {
m.EventID = ids.ID(*ev)
}
if reply != nil {
m.ReplyTo = ids.ID(*reply)
}
if call != nil {
m.LLMCallID = ids.ID(*call)
}
if intent != nil {
m.Intent = *intent
}
return m, nil
}
// Reply returns the mind's reply to a user message, if any.
func (s *Store) Reply(ctx context.Context, mind, userMessage ids.ID) (Message, error) {
return scanMessage(s.db.QueryRow(ctx, `SELECT id, conversation_id, mind_id, role, actor, content, event_id, reply_to, intent, llm_call_id, created_at FROM messages WHERE reply_to=$1 AND mind_id=$2 ORDER BY created_at LIMIT 1`, userMessage, mind))
}
// Recent returns the last n messages of a conversation for context.
func (s *Store) Recent(ctx context.Context, mind, conv ids.ID, n int) ([]Message, error) {
msgs, err := s.Messages(ctx, mind, conv, n)
return msgs, err
}
// GetMessage fetches one message.
func (s *Store) GetMessage(ctx context.Context, mind, id ids.ID) (Message, error) {
return scanMessage(s.db.QueryRow(ctx, `SELECT id, conversation_id, mind_id, role, actor, content, event_id, reply_to, intent, llm_call_id, created_at FROM messages WHERE id=$1 AND mind_id=$2`, id, mind))
}
+51
View File
@@ -0,0 +1,51 @@
package conversation
import (
"context"
"testing"
"github.com/andydixon/noema/internal/minds"
"github.com/andydixon/noema/internal/storage"
"github.com/andydixon/noema/migrations"
"github.com/andydixon/noema/pkg/ids"
)
func TestStoreScopingAndReplies(t *testing.T) {
pool := storage.TestPool(t, migrations.FS)
ctx := context.Background()
ms := minds.NewStore(pool)
a, _ := ms.Create(ctx, minds.CreateParams{Slug: "a"})
b, _ := ms.Create(ctx, minds.CreateParams{Slug: "b"})
s := NewStore(pool)
c, err := s.Create(ctx, a.ID, "", "web", "andy", "noema")
if err != nil {
t.Fatal(err)
}
um, err := s.AddMessage(ctx, pool, Message{ConversationID: c.ID, MindID: a.ID, Role: "user", Actor: "andy", Content: "Is atlas slow?"})
if err != nil {
t.Fatal(err)
}
if _, err := s.AddMessage(ctx, pool, Message{ConversationID: c.ID, MindID: b.ID, Role: "user", Content: "leak?"}); err == nil {
t.Log("cross-mind insert silently ignored (no row)")
}
msgsB, _ := s.Messages(ctx, b.ID, c.ID, 10)
if len(msgsB) != 0 {
t.Fatal("cross-mind message visible")
}
if _, err := s.Get(ctx, b.ID, c.ID); err == nil {
t.Fatal("cross-mind conversation read")
}
rm, _ := s.AddMessage(ctx, pool, Message{ConversationID: c.ID, MindID: a.ID, Role: "mind", Actor: "a", Content: "Somewhat.", ReplyTo: um.ID, LLMCallID: ids.New()})
got, err := s.Reply(ctx, a.ID, um.ID)
if err != nil || got.ID != rm.ID {
t.Fatalf("reply lookup: %v %+v", err, got)
}
cv, _ := s.Get(ctx, a.ID, c.ID)
if cv.Title != "Is atlas slow?" {
t.Fatalf("title not derived: %q", cv.Title)
}
msgs, _ := s.Messages(ctx, a.ID, c.ID, 10)
if len(msgs) != 2 || msgs[0].Role != "user" {
t.Fatalf("ordering wrong: %+v", msgs)
}
}
+85
View File
@@ -0,0 +1,85 @@
package ui
import (
"encoding/json"
"net/http"
"github.com/andydixon/noema/internal/auth"
"github.com/andydixon/noema/internal/llm"
"github.com/andydixon/noema/pkg/ids"
)
func (u *UI) conversationRoutes(m *http.ServeMux) {
m.Handle("GET /minds/{mind}/conversations", u.require(auth.RoleObserver, u.conversations))
m.Handle("POST /minds/{mind}/conversations", u.require(auth.RoleOperator, u.conversationCreate))
m.Handle("GET /minds/{mind}/conversations/{id}", u.require(auth.RoleObserver, u.conversationPage))
m.Handle("POST /minds/{mind}/conversations/{id}/messages", u.require(auth.RoleOperator, u.conversationPost))
}
var suggestions = []string{"What are you thinking about?", "What do you believe about atlas?", "Why do you believe that?", "What are you uncertain about?", "Which of your beliefs contradict each other?", "What did you learn this week?", "What have you changed your mind about?"}
func (u *UI) conversations(w http.ResponseWriter, r *http.Request) {
m, ok := u.mind(w, r)
if !ok {
return
}
list, _ := u.deps.Conversations.List(r.Context(), m.ID, 100)
u.render(w, r, "conversations", m.DisplayName+" · Conversations", map[string]any{"Mind": m, "Conversations": list})
}
func (u *UI) conversationCreate(w http.ResponseWriter, r *http.Request) {
m, ok := u.mind(w, r)
if !ok {
return
}
r.ParseForm() //nolint:errcheck
c, err := u.deps.Conversations.Create(r.Context(), m.ID, r.PostFormValue("title"), "web", r.PostFormValue("person"), r.PostFormValue("project"))
if err != nil {
u.flash(w, r, "error", err.Error(), "/minds/"+m.Slug+"/conversations")
return
}
http.Redirect(w, r, "/minds/"+m.Slug+"/conversations/"+c.ID.String(), http.StatusSeeOther)
}
func (u *UI) conversationPage(w http.ResponseWriter, r *http.Request) {
m, ok := u.mind(w, r)
if !ok {
return
}
id, err := ids.Parse(r.PathValue("id"))
if err != nil {
u.errorPage(w, r, 404, "No such conversation.")
return
}
c, err := u.deps.Conversations.Get(r.Context(), m.ID, id)
if err != nil {
u.errorPage(w, r, 404, "No such conversation.")
return
}
msgs, _ := u.deps.Conversations.Messages(r.Context(), m.ID, id, 200)
u.render(w, r, "conversation", c.Title, map[string]any{"Mind": m, "Conversation": c, "Messages": msgs, "Suggestions": suggestions, "LLM": u.deps.LLM.Available(r.Context(), llm.RoleRespond)})
}
func (u *UI) conversationPost(w http.ResponseWriter, r *http.Request) {
m, ok := u.mind(w, r)
if !ok {
return
}
id, err := ids.Parse(r.PathValue("id"))
if err != nil {
u.errorPage(w, r, 404, "No such conversation.")
return
}
c, err := u.deps.Conversations.Get(r.Context(), m.ID, id)
if err != nil {
u.errorPage(w, r, 404, "No such conversation.")
return
}
if _, err := u.postF(r, m.ID, c, r.PostFormValue("text"), username(r)); err != nil {
u.flash(w, r, "error", err.Error(), "/minds/"+m.Slug+"/conversations/"+id.String())
return
}
http.Redirect(w, r, "/minds/"+m.Slug+"/conversations/"+id.String(), http.StatusSeeOther)
}
func prettyRaw(raw json.RawMessage) string { return pretty(raw) }
+1
View File
@@ -88,6 +88,7 @@ func (u *UI) mindsCreate(w http.ResponseWriter, r *http.Request) {
return return
} }
u.deps.Audit.Log(r.Context(), "mind.create", "mind", m.ID.String(), map[string]any{"slug": m.Slug}) u.deps.Audit.Log(r.Context(), "mind.create", "mind", m.ID.String(), map[string]any{"slug": m.Slug})
u.deps.Brain.Reconcile(r.Context()) //nolint:errcheck
u.flash(w, r, "ok", "Mind created. Shape its personality next.", "/minds/"+m.Slug+"/personality") u.flash(w, r, "ok", "Mind created. Shape its personality next.", "/minds/"+m.Slug+"/personality")
} }
+6 -2
View File
@@ -21,6 +21,7 @@ import (
"github.com/andydixon/noema/internal/attention" "github.com/andydixon/noema/internal/attention"
"github.com/andydixon/noema/internal/auth" "github.com/andydixon/noema/internal/auth"
"github.com/andydixon/noema/internal/beliefs" "github.com/andydixon/noema/internal/beliefs"
"github.com/andydixon/noema/internal/conversation"
"github.com/andydixon/noema/internal/llm" "github.com/andydixon/noema/internal/llm"
"github.com/andydixon/noema/internal/minds" "github.com/andydixon/noema/internal/minds"
"github.com/andydixon/noema/internal/version" "github.com/andydixon/noema/internal/version"
@@ -40,6 +41,7 @@ type UI struct {
memF func(r *http.Request, mind, id ids.ID) (api.MemoryDetail, error) memF func(r *http.Request, mind, id ids.ID) (api.MemoryDetail, error)
belF func(r *http.Request, mind, id ids.ID) (api.BeliefDetail, error) belF func(r *http.Request, mind, id ids.ID) (api.BeliefDetail, error)
emitF func() beliefs.Emitter emitF func() beliefs.Emitter
postF func(r *http.Request, mind ids.ID, conv conversation.Conversation, text, who string) (conversation.Message, error)
} }
// View is the data passed to every template. // View is the data passed to every template.
@@ -60,7 +62,7 @@ const flashCookie = "noema_flash"
// New parses templates. In development, when ./web exists on disk, templates // New parses templates. In development, when ./web exists on disk, templates
// and static files are read from disk so edits show without a rebuild. // and static files are read from disk so edits show without a rebuild.
func New(deps app.Deps, statusF func(r *http.Request) any, ctxF func(r *http.Request, id ids.ID) (api.EventContext, error), wsF func(r *http.Request, m minds.Mind) (api.WorkspaceView, error), memF func(r *http.Request, mind, id ids.ID) (api.MemoryDetail, error), belF func(r *http.Request, mind, id ids.ID) (api.BeliefDetail, error), emitF func() beliefs.Emitter) (*UI, error) { func New(deps app.Deps, statusF func(r *http.Request) any, ctxF func(r *http.Request, id ids.ID) (api.EventContext, error), wsF func(r *http.Request, m minds.Mind) (api.WorkspaceView, error), memF func(r *http.Request, mind, id ids.ID) (api.MemoryDetail, error), belF func(r *http.Request, mind, id ids.ID) (api.BeliefDetail, error), emitF func() beliefs.Emitter, postF func(r *http.Request, mind ids.ID, conv conversation.Conversation, text, who string) (conversation.Message, error)) (*UI, error) {
tmplFS, staticFS := web.Templates(), web.Static() tmplFS, staticFS := web.Templates(), web.Static()
if !deps.Config.Production() { if !deps.Config.Production() {
if st, err := os.Stat("web/templates"); err == nil && st.IsDir() { if st, err := os.Stat("web/templates"); err == nil && st.IsDir() {
@@ -68,7 +70,7 @@ func New(deps app.Deps, statusF func(r *http.Request) any, ctxF func(r *http.Req
deps.Log.Info("ui: serving templates and static files from ./web (development)") deps.Log.Info("ui: serving templates and static files from ./web (development)")
} }
} }
u := &UI{deps: deps, static: staticFS, statusF: statusF, ctxF: ctxF, wsF: wsF, memF: memF, belF: belF, emitF: emitF} u := &UI{deps: deps, static: staticFS, statusF: statusF, ctxF: ctxF, wsF: wsF, memF: memF, belF: belF, emitF: emitF, postF: postF}
if err := u.parse(tmplFS); err != nil { if err := u.parse(tmplFS); err != nil {
return nil, err return nil, err
} }
@@ -120,6 +122,7 @@ func (u *UI) parse(fsys fs.FS) error {
"epistemicKinds": func() []concepts.Epistemic { return concepts.AllEpistemic }, "epistemicKinds": func() []concepts.Epistemic { return concepts.AllEpistemic },
"relations": func() []concepts.Relation { return concepts.AllRelations }, "relations": func() []concepts.Relation { return concepts.AllRelations },
"llmRoles": func() []llm.RoleInfo { return llm.Roles }, "llmRoles": func() []llm.RoleInfo { return llm.Roles },
"pretty": prettyRaw,
"deref_id": func(id *ids.ID) ids.ID { "deref_id": func(id *ids.ID) ids.ID {
if id == nil { if id == nil {
return ids.Nil return ids.Nil
@@ -297,6 +300,7 @@ func (u *UI) Register(m *http.ServeMux) {
u.memoryRoutes(m) u.memoryRoutes(m)
u.beliefRoutes(m) u.beliefRoutes(m)
u.llmRoutes(m) u.llmRoutes(m)
u.conversationRoutes(m)
for _, p := range []struct{ path, title, note string }{ for _, p := range []struct{ path, title, note string }{
{"/approvals", "Approvals", "The approval centre arrives with the capability system."}, {"/approvals", "Approvals", "The approval centre arrives with the capability system."},
+2
View File
@@ -0,0 +1,2 @@
DROP TABLE IF EXISTS messages;
DROP TABLE IF EXISTS conversations;
+31
View File
@@ -0,0 +1,31 @@
-- Conversations and messages. A user message is also an observation event;
-- a mind message is also a response_generated event. Rows here are the
-- conversational view; provenance lives in the events they reference.
CREATE TABLE conversations (
id uuid PRIMARY KEY,
mind_id uuid NOT NULL REFERENCES minds(id),
title text NOT NULL DEFAULT '',
channel text NOT NULL DEFAULT 'web',
person text NOT NULL DEFAULT '',
project text NOT NULL DEFAULT '',
context jsonb NOT NULL DEFAULT '{}'::jsonb,
created_at timestamptz NOT NULL DEFAULT now(),
last_message_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX conversations_mind_idx ON conversations (mind_id, last_message_at DESC);
CREATE TABLE messages (
id uuid PRIMARY KEY,
conversation_id uuid NOT NULL REFERENCES conversations(id),
mind_id uuid NOT NULL REFERENCES minds(id),
role text NOT NULL CHECK (role IN ('user','mind','system')),
actor text NOT NULL DEFAULT '',
content text NOT NULL,
event_id uuid,
reply_to uuid REFERENCES messages(id),
intent jsonb,
llm_call_id uuid,
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX messages_conversation_idx ON messages (conversation_id, created_at);
CREATE INDEX messages_reply_idx ON messages (reply_to) WHERE reply_to IS NOT NULL;
+238
View File
@@ -0,0 +1,238 @@
// Package m1 is the acceptance test for the first usable milestone: start,
// log in, create a mind, shape its personality, configure a model, chat,
// watch events, inspect working memory, the workspace, memories, beliefs and
// provenance, restart, and continue the same mind.
package m1
import (
"context"
"encoding/json"
"fmt"
"log/slog"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/andydixon/noema/internal/api"
"github.com/andydixon/noema/internal/app"
"github.com/andydixon/noema/internal/audit"
"github.com/andydixon/noema/internal/auth"
"github.com/andydixon/noema/internal/beliefs"
"github.com/andydixon/noema/internal/brain"
"github.com/andydixon/noema/internal/cognition"
"github.com/andydixon/noema/internal/config"
"github.com/andydixon/noema/internal/conversation"
evstore "github.com/andydixon/noema/internal/events"
"github.com/andydixon/noema/internal/llm"
"github.com/andydixon/noema/internal/llm/prompts"
"github.com/andydixon/noema/internal/llm/router"
"github.com/andydixon/noema/internal/memory"
"github.com/andydixon/noema/internal/minds"
"github.com/andydixon/noema/internal/observability"
"github.com/andydixon/noema/internal/scheduler"
"github.com/andydixon/noema/internal/security/secrets"
"github.com/andydixon/noema/internal/storage"
"github.com/andydixon/noema/migrations"
"github.com/andydixon/noema/pkg/events"
)
type harness struct {
t *testing.T
deps app.Deps
group *scheduler.Group
srv *httptest.Server
token string
}
// start assembles the daemon the way cmd/noemad does, with a fake model.
func start(t *testing.T, deps app.Deps, perceive, respond llm.LanguageModel) *harness {
t.Helper()
ctx := context.Background()
group := scheduler.NewGroup(ctx, deps.Log)
registry := brain.NewRegistry().AddProcess(cognition.Attend{}).AddProcess(cognition.Retrieve{}).AddProcess(cognition.Associate{}).AddProcess(cognition.Perceive{})
cognition.Handlers{Memory: deps.Memory, Beliefs: deps.Beliefs, Conversations: deps.Conversations}.Register(registry)
deps.LLM.Override = map[llm.Role]llm.LanguageModel{llm.RolePerceive: perceive, llm.RoleRespond: respond}
deps.Brain = brain.NewManager(brain.Config{DB: deps.DB, Events: deps.Events, Minds: deps.Minds, Registry: registry, Pool: scheduler.NewPool(4), Log: deps.Log, Metrics: deps.Metrics,
Services: brain.Services{Events: deps.Events, Memory: deps.Memory, Beliefs: deps.Beliefs, LLM: deps.LLM, Prompts: deps.Prompts}}, group)
if err := deps.Brain.Start(ctx); err != nil {
t.Fatal(err)
}
s := api.New(deps, nil)
h := &harness{t: t, deps: deps, group: group, srv: httptest.NewServer(s.Handler())}
return h
}
func (h *harness) stop() {
h.srv.Close()
h.group.Stop()
}
func (h *harness) do(method, path string, body any, out any) int {
h.t.Helper()
var rd *strings.Reader
if body != nil {
b, _ := json.Marshal(body)
rd = strings.NewReader(string(b))
} else {
rd = strings.NewReader("")
}
req, _ := http.NewRequest(method, h.srv.URL+path, rd)
req.Header.Set("Authorization", "Bearer "+h.token)
req.Header.Set("Content-Type", "application/json")
res, err := http.DefaultClient.Do(req)
if err != nil {
h.t.Fatal(err)
}
defer res.Body.Close()
if out != nil {
json.NewDecoder(res.Body).Decode(out) //nolint:errcheck
}
return res.StatusCode
}
func TestFirstMilestone(t *testing.T) {
pool := storage.TestPool(t, migrations.FS)
ctx := context.Background()
log := slog.New(slog.DiscardHandler)
cfg := config.Defaults()
cfg.DatabaseURL = "postgres://test"
sec, _ := secrets.New("")
llmStore := router.NewStore(pool)
deps := app.Deps{Config: cfg, Log: log, DB: pool, Auth: auth.NewService(pool, time.Hour, 2*time.Hour), Audit: audit.New(pool, log), Metrics: observability.NewMetrics(),
Minds: minds.NewStore(pool), Events: evstore.NewStore(pool, evstore.NewBus(64), nil), Memory: memory.NewStore(ctx, pool, nil), Beliefs: beliefs.NewStore(pool),
Conversations: conversation.NewStore(pool), Secrets: sec, LLMStore: llmStore, LLM: router.NewRouter(llmStore, sec, log, nil, 2), Prompts: prompts.NewStore(pool), Migrations: migrations.FS, Started: time.Now()}
if _, err := deps.Prompts.SeedDefaults(ctx); err != nil {
t.Fatal(err)
}
// 1–2. Start and "log in": an administrator with an API token.
u, err := deps.Auth.CreateUser(ctx, "admin", "administrator-pass", auth.RoleAdministrator)
if err != nil {
t.Fatal(err)
}
token, _ := deps.Auth.CreateAPIToken(ctx, u.ID, "m1", nil)
perceive := &llm.Fake{ModelName: "fake-perceive", Script: []string{`{"intent":"statement","summary":"claims a disk failure","entities":[{"name":"atlas","kind":"system"}],"claims":[{"proposition":"atlas has a failing disk","certainty":"asserted"}],"questions":[],"urgency":0.6,"sentiment":-0.3}`}}
respond := &llm.Fake{ModelName: "fake-respond", Script: []string{"Noted: I now hold that atlas has a failing disk, on your word alone.", "Atlas: I believe its disk is failing, tentatively.", "Still here after the restart; atlas' disk is still my concern."}}
h := start(t, deps, perceive, respond)
h.token = token
// 3. Create a mind.
var mind minds.Mind
if code := h.do("POST", "/api/v1/minds", map[string]any{"slug": "m1", "display_name": "M1"}, &mind); code != 201 {
t.Fatalf("create mind: %d", code)
}
// 4. Configure personality.
p := mind.Personality
p.Identity.Role = "acceptance tester"
p.Traits["scepticism"] = 0.9
var ver minds.Version
if code := h.do("PUT", "/api/v1/minds/m1/personality", map[string]any{"personality": p, "change_note": "m1"}, &ver); code != 200 || ver.Version != 2 {
t.Fatalf("personality: %d %+v", code, ver)
}
// 5. Configure an LLM: a provider and model exist in configuration; calls are served by the fake via role override.
var prov router.Provider
if code := h.do("POST", "/api/v1/llm/providers", map[string]any{"name": "fake", "kind": "ollama", "endpoint": "http://127.0.0.1:1", "allow_private": true}, &prov); code != 201 {
t.Fatalf("provider: %d", code)
}
// 6. Chat: a statement, then a question.
var ask struct {
Reply *conversation.Message `json:"reply"`
Note string `json:"note"`
}
if code := h.do("POST", "/api/v1/minds/m1/ask", map[string]any{"text": "atlas has a failing disk.", "wait_seconds": 20}, &ask); code != 200 || ask.Reply == nil {
t.Fatalf("ask 1: %d %+v", code, ask)
}
if !strings.Contains(ask.Reply.Content, "Noted") || ask.Reply.LLMCallID.IsZero() || ask.Reply.EventID.IsZero() {
t.Fatalf("reply 1 not from model with provenance: %+v", ask.Reply)
}
var intent cognition.Intent
json.Unmarshal(ask.Reply.Intent, &intent) //nolint:errcheck
if intent.Kind != "acknowledge" || len(intent.Beliefs) != 1 {
t.Fatalf("intent 1 wrong: %+v", intent)
}
if code := h.do("POST", "/api/v1/minds/m1/ask", map[string]any{"text": "What do you believe about atlas?", "wait_seconds": 20}, &ask); code != 200 || ask.Reply == nil {
t.Fatalf("ask 2: %d %+v", code, ask)
}
json.Unmarshal(ask.Reply.Intent, &intent) //nolint:errcheck
if intent.Kind != "introspect" || len(intent.Beliefs) == 0 || !strings.Contains(intent.Beliefs[0].Proposition, "failing disk") {
t.Fatalf("intent 2 not grounded in the recorded belief: %+v", intent)
}
// 7. Cognitive events appeared, with the pipeline shape.
var evs struct{ Items []events.Event }
h.do("GET", "/api/v1/minds/m1/events?limit=200", nil, &evs)
types := map[events.Type]int{}
for _, e := range evs.Items {
types[e.Type]++
}
for _, want := range []events.Type{events.Observation, events.Perception, events.ThoughtCandidate, events.ThoughtSelected, events.WorkspaceBroadcast, events.MemoryCreated, events.BeliefCreated, events.LLMResponse, events.ResponseGenerated} {
if types[want] == 0 {
t.Fatalf("missing event type %s in %v", want, types)
}
}
// 8–9. Working memory and workspace are inspectable.
var ws api.WorkspaceView
h.do("GET", "/api/v1/minds/m1/workspace", nil, &ws)
if !ws.Running || ws.State.Cycle < 2 || len(ws.State.WorkingMemory) == 0 || len(ws.Candidates) == 0 {
t.Fatalf("workspace: %+v", ws.State)
}
// 10. Episodic memories.
var mems struct{ Items []memory.Memory }
h.do("GET", "/api/v1/minds/m1/memories?kind=episodic", nil, &mems)
if len(mems.Items) < 2 {
t.Fatalf("expected episodic memories, got %d", len(mems.Items))
}
// 11–12. Beliefs with provenance back to the message.
var bs struct{ Items []beliefs.Belief }
h.do("GET", "/api/v1/minds/m1/beliefs", nil, &bs)
if len(bs.Items) != 1 || bs.Items[0].Epistemic != "claim" {
t.Fatalf("beliefs: %+v", bs.Items)
}
var detail api.BeliefDetail
h.do("GET", "/api/v1/minds/m1/beliefs/"+string(bs.Items[0].ID), nil, &detail)
if len(detail.Evidence) != 1 || detail.Evidence[0].Kind != "event" || detail.Breakdown.SourcesFor != 1 {
t.Fatalf("provenance: %+v", detail)
}
var evCtx api.EventContext
h.do("GET", "/api/v1/events/"+string(detail.Evidence[0].RefID)+"/context", nil, &evCtx)
if evCtx.Event.Type != events.Observation || len(evCtx.Children) == 0 {
t.Fatalf("event context: %+v", evCtx.Event)
}
var respCtx api.EventContext
h.do("GET", "/api/v1/events/"+string(ask.Reply.EventID)+"/context", nil, &respCtx)
var citesBelief bool
for _, ref := range respCtx.Event.Evidence {
if ref.Kind == "belief" {
citesBelief = true
}
}
if !citesBelief || respCtx.Cause == nil || respCtx.Cause.Type != events.Perception {
t.Fatalf("reply event lacks provenance: %+v", respCtx.Event)
}
// 13. Restart everything except the database.
cycleBefore := ws.State.Cycle
h.stop()
h2 := start(t, deps, perceive, respond)
defer h2.stop()
h2.token = token
var ws2 api.WorkspaceView
h2.do("GET", "/api/v1/minds/m1/workspace", nil, &ws2)
if !ws2.Running || ws2.State.Cycle != cycleBefore || len(ws2.State.WorkingMemory) != len(ws.State.WorkingMemory) {
t.Fatalf("state not continued after restart: before %d/%d after %d/%d", cycleBefore, len(ws.State.WorkingMemory), ws2.State.Cycle, len(ws2.State.WorkingMemory))
}
// 14. Continue the same mind: it still holds the belief and answers.
if code := h2.do("POST", "/api/v1/minds/m1/ask", map[string]any{"text": "What do you believe about atlas?", "wait_seconds": 20}, &ask); code != 200 || ask.Reply == nil {
t.Fatalf("ask after restart: %d %+v", code, ask)
}
json.Unmarshal(ask.Reply.Intent, &intent) //nolint:errcheck
if len(intent.Beliefs) == 0 {
t.Fatalf("belief lost across restart: %+v", intent)
}
var bs2 struct{ Items []beliefs.Belief }
h2.do("GET", "/api/v1/minds/m1/beliefs", nil, &bs2)
if len(bs2.Items) != 1 || bs2.Items[0].ID != bs.Items[0].ID {
t.Fatalf("belief identity changed: %+v", bs2.Items)
}
fmt.Println("M1 acceptance: events", len(evs.Items), "cycles", ws2.State.Cycle)
}
+11
View File
@@ -179,3 +179,14 @@ ul.ev li { padding: .4rem 0; border-bottom: 1px solid var(--border); } ul.ev li:
/* Prompts */ /* Prompts */
textarea.mono { font-family: var(--mono); font-size: .85rem; } textarea.mono { font-family: var(--mono); font-size: .85rem; }
pre.diff .add { color: var(--ok); } pre.diff .del { color: var(--danger); } pre.diff .ctx { color: var(--muted); } pre.diff .add { color: var(--ok); } pre.diff .del { color: var(--danger); } pre.diff .ctx { color: var(--muted); }
/* Conversation */
ol.messages { list-style: none; padding: 0; margin: 0 0 1rem; display: flex; flex-direction: column; gap: .6rem; max-height: 60vh; overflow-y: auto; }
.msg { padding: .6rem .8rem; border-radius: 10px; max-width: 85%; }
.msg-user { background: var(--accent-soft); align-self: flex-end; }
.msg-mind { background: var(--surface-2); align-self: flex-start; }
.msg-system { background: transparent; border: 1px dashed var(--border); align-self: center; }
.msg-body { white-space: pre-wrap; }
.msg-head { margin-bottom: .2rem; }
.chat-input input { min-width: 0; }
.badge.small { font-size: .65rem; }
+34
View File
@@ -0,0 +1,34 @@
{{define "content"}}
{{$m := .Data.Mind}}{{$c := .Data.Conversation}}
<div class="pagehead mindhead"><div><p class="muted small"><a href="/minds/{{$m.Slug}}/conversations">conversations</a> › {{$c.Channel}}{{if $c.Person}} · {{$c.Person}}{{end}}{{if $c.Project}} · {{$c.Project}}{{end}}</p><h1>{{if $c.Title}}{{$c.Title}}{{else}}Conversation with {{$m.DisplayName}}{{end}}</h1></div>
<div class="row"><span data-stream-status class="badge">connecting…</span>{{if not .Data.LLM}}<span class="badge warn" title="Replies are deterministic renderings of state until a model is assigned to the respond role">no respond model</span>{{end}}</div></div>
<section class="grid cols-3-1">
<div class="card chat">
<div id="chat-live" data-refresh-on="response_generated,observation" data-refresh-url="/minds/{{$m.Slug}}/conversations/{{$c.ID}}" data-stream="/api/v1/minds/{{$m.Slug}}/events/stream?type=response_generated,observation">
<ol class="messages">{{range .Data.Messages}}<li class="msg msg-{{.Role}}">
<div class="msg-head"><strong>{{.Actor}}</strong> <span class="muted small">{{.CreatedAt.Local.Format "15:04:05"}}</span>{{if .EventID}} <a class="small" href="/events/{{.EventID}}">event</a>{{end}}{{if .LLMCallID}} <span class="badge small">model</span>{{else if eq .Role "mind"}} <span class="badge small" title="Deterministic rendering">state</span>{{end}}</div>
<div class="msg-body">{{.Content}}</div>
{{if .Intent}}<details class="small muted"><summary>why this reply</summary><pre class="json">{{pretty .Intent}}</pre></details>{{end}}
</li>{{else}}<li class="muted">Say something.</li>{{end}}</ol>
</div>
{{if .Principal.User.Role.AtLeast "operator"}}
<form method="post" action="/minds/{{$m.Slug}}/conversations/{{$c.ID}}/messages" class="row chat-input">
<input type="hidden" name="_csrf" value="{{.CSRF}}">
<input name="text" class="grow" placeholder="Ask {{$m.DisplayName}} something… e.g. What are you thinking about?" required maxlength="20000" autofocus autocomplete="off">
<button class="btn btn-primary" type="submit">Send</button>
</form>
{{end}}
</div>
<div class="stack">
<div class="card small">
<h2>How a reply forms</h2>
<ol class="steps"><li>message → <span class="etype cat-perception">observation</span></li><li><span class="etype cat-perception">perception</span>: intent, entities, claims</li><li>recall + workspace competition</li><li>intent built from beliefs and memories</li><li>wording by the respond model, or a direct rendering of state</li><li><span class="etype cat-cognition">response_generated</span> with cited evidence</li></ol>
<p class="muted">Open “why this reply” under any answer, or its event, to see the evidence.</p>
</div>
<div class="card small">
<h2>Try asking</h2>
<ul class="plain muted">{{range .Data.Suggestions}}<li>“{{.}}”</li>{{end}}</ul>
</div>
</div>
</section>
{{end}}
+24
View File
@@ -0,0 +1,24 @@
{{define "content"}}
{{$m := .Data.Mind}}
<div class="pagehead mindhead"><div><h1>{{$m.DisplayName}} <span class="muted small">conversations</span></h1><p class="muted">Messages become observations; replies are grounded in beliefs and memories and carry their provenance.</p></div></div>
{{template "mind_tabs" (dict "M" $m "Active" "conversation")}}
<section class="grid cols-3-1">
<div class="card">
{{if .Data.Conversations}}<table class="table compact"><thead><tr><th>Title</th><th>Person</th><th>Project</th><th>Channel</th><th>Last message</th></tr></thead>
<tbody>{{range .Data.Conversations}}<tr><td><a href="/minds/{{$m.Slug}}/conversations/{{.ID}}">{{if .Title}}{{.Title}}{{else}}<em class="muted">untitled</em>{{end}}</a></td><td class="small">{{.Person}}</td><td class="small">{{.Project}}</td><td class="small muted">{{.Channel}}</td><td class="small muted">{{ago .LastMessageAt}}</td></tr>{{end}}</tbody></table>
{{else}}<p class="muted">No conversations yet.</p>{{end}}
</div>
{{if .Principal.User.Role.AtLeast "operator"}}
<div class="card">
<h2>New conversation</h2>
<form method="post" action="/minds/{{$m.Slug}}/conversations" class="stack">
<input type="hidden" name="_csrf" value="{{.CSRF}}">
<label>Person <input name="person" value="{{.Principal.User.Username}}"></label>
<label>Project <input name="project" placeholder="optional"></label>
<label>Title <input name="title" placeholder="optional; taken from the first message"></label>
<button class="btn btn-primary" type="submit">Start</button>
</form>
</div>
{{end}}
</section>
{{end}}
+1 -1
View File
@@ -3,6 +3,6 @@
<a href="/minds/{{$m.Slug}}" {{if eq $a "overview"}}aria-current="page"{{end}}>Overview</a> <a href="/minds/{{$m.Slug}}" {{if eq $a "overview"}}aria-current="page"{{end}}>Overview</a>
<a href="/minds/{{$m.Slug}}/thoughts" {{if eq $a "thoughts"}}aria-current="page"{{end}}>Thoughts</a> <a href="/minds/{{$m.Slug}}/thoughts" {{if eq $a "thoughts"}}aria-current="page"{{end}}>Thoughts</a>
<a href="/minds/{{$m.Slug}}/personality" {{if eq $a "personality"}}aria-current="page"{{end}}>Personality</a> <a href="/minds/{{$m.Slug}}/personality" {{if eq $a "personality"}}aria-current="page"{{end}}>Personality</a>
<a href="/minds/{{$m.Slug}}/workspace" {{if eq $a "workspace"}}aria-current="page"{{end}}>Workspace</a><a href="/minds/{{$m.Slug}}/memory" {{if eq $a "memory"}}aria-current="page"{{end}}>Memory</a><a href="/minds/{{$m.Slug}}/beliefs" {{if eq $a "beliefs"}}aria-current="page"{{end}}>Beliefs</a><a class="disabled" title="Phase 8">Conversation</a> <a href="/minds/{{$m.Slug}}/workspace" {{if eq $a "workspace"}}aria-current="page"{{end}}>Workspace</a><a href="/minds/{{$m.Slug}}/memory" {{if eq $a "memory"}}aria-current="page"{{end}}>Memory</a><a href="/minds/{{$m.Slug}}/beliefs" {{if eq $a "beliefs"}}aria-current="page"{{end}}>Beliefs</a><a href="/minds/{{$m.Slug}}/conversations" {{if eq $a "conversation"}}aria-current="page"{{end}}>Conversation</a>
</nav> </nav>
{{end}} {{end}}