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:
@@ -5,6 +5,7 @@ All notable changes to Noema are recorded here. Format follows Keep a Changelog;
|
||||
## [Unreleased]
|
||||
|
||||
### 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 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.
|
||||
|
||||
@@ -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
|
||||
- ☑ 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)
|
||||
- ☐ `migrations/0008_conversations`: conversations, messages
|
||||
- ☐ `internal/perception`: observation events with trust tags; deterministic message analysis; LLM-assisted intent/concept extraction
|
||||
- ☐ Executive: response intent → language generation with personality, self-model, cited beliefs and uncertainty; "I do not know" path
|
||||
- ☐ Episodic memory creation from conversation; basic belief formation from explicit user statements
|
||||
- ☐ UI: conversation page with live cognition side panel and provenance per reply
|
||||
- ☐ `noemactl ask`
|
||||
- ☐ Persistence and restart continuity verified end-to-end
|
||||
- ☐ Sample mind Dani (`configs/minds/dani.json`) and import path
|
||||
- ☐ M1 acceptance test in `tests/m1`
|
||||
### Phase 8 – Conversation (completes M1) ☑
|
||||
- ☑ `migrations/0008_conversations`: conversations (person, project, channel), messages linked to events, intents and model calls
|
||||
- ☑ `internal/conversation`: mind-scoped store; deterministic analysis (intent, introspection kind, entities, claims with certainty, urgency, sentiment, topic)
|
||||
- ☑ `cognition/perceive` process: conversational observations become high-priority candidates, optionally enriched by the perceive prompt within the per-cycle model cap
|
||||
- ☑ `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
|
||||
- ☑ Per-cycle model allowance shared by processes and handlers; attend handler defers conversational episodes to perceive
|
||||
- ☑ API: conversations, messages with optional wait, one-shot `ask`; mind creation starts its runtime immediately
|
||||
- ☑ UI: conversation list, chat page with live refresh, per-reply “why this reply” intent and event links
|
||||
- ☑ Sample mind `configs/minds/dani.json` (schema noema.mind-seed/1) with strict import endpoint and `noemactl mind import`; `noemactl ask`
|
||||
- ☑ `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
|
||||
- ☐ goals (tree, priority, progress, blockers, origin), goal monitor process
|
||||
|
||||
@@ -48,7 +48,7 @@ cp .env.example .env
|
||||
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
|
||||
|
||||
@@ -103,7 +103,7 @@ noemactl fork dani --snapshot <id> --name dani-test
|
||||
|
||||
## 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
|
||||
|
||||
|
||||
@@ -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
@@ -17,7 +17,7 @@ func init() {
|
||||
|
||||
func cmdMind(args []string) int {
|
||||
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
|
||||
}
|
||||
switch args[0] {
|
||||
@@ -86,6 +86,31 @@ func cmdMind(args []string) int {
|
||||
}
|
||||
fmt.Printf("cloned into %v (%v)\n", m["slug"], m["id"])
|
||||
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":
|
||||
if len(args) < 2 {
|
||||
fmt.Fprintln(os.Stderr, "usage: noemactl mind personality <mind>")
|
||||
|
||||
+8
-3
@@ -21,6 +21,7 @@ import (
|
||||
"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/prompts"
|
||||
"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.Beliefs = beliefs.NewStore(db)
|
||||
registry := brain.NewRegistry().AddProcess(cognition.Attend{}).AddProcess(cognition.Retrieve{}).AddProcess(cognition.Associate{})
|
||||
cognition.Handlers{Memory: deps.Memory, Beliefs: deps.Beliefs}.Register(registry)
|
||||
deps.Conversations = conversation.NewStore(db)
|
||||
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),
|
||||
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) })
|
||||
@@ -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, 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() 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 {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -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"}
|
||||
]
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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() })
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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) }
|
||||
@@ -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
@@ -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."},
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
DROP TABLE IF EXISTS messages;
|
||||
DROP TABLE IF EXISTS conversations;
|
||||
@@ -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;
|
||||
@@ -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)
|
||||
}
|
||||
@@ -179,3 +179,14 @@ ul.ev li { padding: .4rem 0; border-bottom: 1px solid var(--border); } ul.ev li:
|
||||
/* Prompts */
|
||||
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); }
|
||||
|
||||
/* 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; }
|
||||
|
||||
@@ -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}}
|
||||
@@ -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}}
|
||||
@@ -3,6 +3,6 @@
|
||||
<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}}/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>
|
||||
{{end}}
|
||||
|
||||
Reference in New Issue
Block a user