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
+2 -1
View File
@@ -16,6 +16,7 @@ import (
"github.com/andydixon/noema/internal/beliefs"
"github.com/andydixon/noema/internal/brain"
"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/prompts"
"github.com/andydixon/noema/internal/llm/router"
@@ -38,7 +39,7 @@ func testServer(t *testing.T) (*httptest.Server, string) {
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,
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()
group := scheduler.NewGroup(ctx, log)
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
}
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)
}
+2
View File
@@ -58,6 +58,8 @@ func (s *Server) routes() {
s.beliefRoutes()
s.llmRoutes()
s.promptRoutes()
s.conversationRoutes()
s.importRoutes()
m.HandleFunc("/api/", func(w http.ResponseWriter, r *http.Request) {
writeError(w, r, http.StatusNotFound, "not_found", "no such endpoint")
+19 -17
View File
@@ -15,6 +15,7 @@ import (
"github.com/andydixon/noema/internal/beliefs"
"github.com/andydixon/noema/internal/brain"
"github.com/andydixon/noema/internal/config"
"github.com/andydixon/noema/internal/conversation"
"github.com/andydixon/noema/internal/events"
"github.com/andydixon/noema/internal/llm/prompts"
"github.com/andydixon/noema/internal/llm/router"
@@ -26,21 +27,22 @@ import (
// Deps is everything a handler may need. Fields are added per phase.
type Deps struct {
Config config.Config
Log *slog.Logger
DB *pgxpool.Pool
Auth *auth.Service
Audit *audit.Logger
Metrics *observability.Metrics
Minds *minds.Store
Events *events.Store
Brain *brain.Manager
Memory *memory.Store
Beliefs *beliefs.Store
LLM *router.Router
LLMStore *router.Store
Prompts *prompts.Store
Secrets *secrets.Resolver
Migrations fs.FS
Started time.Time
Config config.Config
Log *slog.Logger
DB *pgxpool.Pool
Auth *auth.Service
Audit *audit.Logger
Metrics *observability.Metrics
Minds *minds.Store
Events *events.Store
Brain *brain.Manager
Memory *memory.Store
Beliefs *beliefs.Store
Conversations *conversation.Store
LLM *router.Router
LLMStore *router.Store
Prompts *prompts.Store
Secrets *secrets.Resolver
Migrations fs.FS
Started time.Time
}
+91
View File
@@ -787,3 +787,94 @@ func trunc(s string, n int) string {
}
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
}
// 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.
func (r *Runtime) Events() *evstore.Store { return r.events }
+16
View File
@@ -6,6 +6,7 @@ package brain
import (
"context"
"sync/atomic"
"time"
"github.com/andydixon/noema/internal/attention"
@@ -65,6 +66,21 @@ type State struct {
NewEvents []events.Event // since the last cycle, ascending
Workspace workspace.State // snapshot before this cycle
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.
+10 -2
View File
@@ -9,6 +9,7 @@ import (
"github.com/andydixon/noema/internal/beliefs"
"github.com/andydixon/noema/internal/brain"
"github.com/andydixon/noema/internal/conversation"
"github.com/andydixon/noema/internal/memory"
"github.com/andydixon/noema/internal/storage"
"github.com/andydixon/noema/internal/workspace"
@@ -19,8 +20,9 @@ import (
// Handlers are the executive actions for admitted thought kinds.
type Handlers struct {
Memory *memory.Store
Beliefs *beliefs.Store
Memory *memory.Store
Beliefs *beliefs.Store
Conversations *conversation.Store
}
// 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("retrieve", h.retrieve)
r.Handle("hypothesis", h.hypothesis)
r.Handle("perceive", h.perceive)
}
// 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
if h.Memory != nil && 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 {
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,
@@ -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)})
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
}
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")
}
+6 -2
View File
@@ -21,6 +21,7 @@ import (
"github.com/andydixon/noema/internal/attention"
"github.com/andydixon/noema/internal/auth"
"github.com/andydixon/noema/internal/beliefs"
"github.com/andydixon/noema/internal/conversation"
"github.com/andydixon/noema/internal/llm"
"github.com/andydixon/noema/internal/minds"
"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)
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)
}
// 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
// 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()
if !deps.Config.Production() {
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)")
}
}
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 {
return nil, err
}
@@ -120,6 +122,7 @@ func (u *UI) parse(fsys fs.FS) error {
"epistemicKinds": func() []concepts.Epistemic { return concepts.AllEpistemic },
"relations": func() []concepts.Relation { return concepts.AllRelations },
"llmRoles": func() []llm.RoleInfo { return llm.Roles },
"pretty": prettyRaw,
"deref_id": func(id *ids.ID) ids.ID {
if id == nil {
return ids.Nil
@@ -297,6 +300,7 @@ func (u *UI) Register(m *http.ServeMux) {
u.memoryRoutes(m)
u.beliefRoutes(m)
u.llmRoutes(m)
u.conversationRoutes(m)
for _, p := range []struct{ path, title, note string }{
{"/approvals", "Approvals", "The approval centre arrives with the capability system."},