Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 6 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -312,12 +312,15 @@ Noema can run as an [MCP](https://modelcontextprotocol.io) server, giving any MC

| Tool | Purpose |
|---|---|
| `get_instructions` | Live reference guide for this Cortex (call first in any new session) |
| `get_instructions` | Concise Markdown guidance for agent use of this Cortex |
| `cortex_usage` | MCP structured content for clients: Cortex identity, trace semantics, startup pattern, runtime posture, and constraints, with a JSON text fallback |
| `list_traces` | List traces, filterable by `type`, `author`, `tag`, `origin`, `archived`, `all` |
| `get_trace` | Fetch a trace's full body, origin, and lineage |
| `create_trace` | Create a new trace (supports `derived_from`, `origin`) |
| `update_trace` | Update any subset of fields on an existing trace |
| `append_trace` | Append content to an existing trace without reading it first (fire-and-forget logging) |
| `set_trace_tags` | Replace a trace's retrieval tags without touching title, body, type, or lineage |
| `append_trace_tags` | Add retrieval tags idempotently without touching title, body, type, or lineage |
| `search_traces` | Search traces. `mode`: `lexical` (FTS5, default), `semantic` (embedding similarity), or `hybrid` (RRF fusion); semantic/hybrid need a configured `search:` block and fall back to lexical otherwise |
| `find_similar_traces` | Surface traces related to one you hold. Default ranks by BM25 vocabulary overlap; `mode=semantic`/`hybrid` ranks by embedding similarity to the source trace's vector |
| `archive_trace` / `unarchive_trace` | Archive a trace or restore it |
Expand All @@ -331,7 +334,7 @@ Noema can run as an [MCP](https://modelcontextprotocol.io) server, giving any MC

`delete_trace` moves a trace to trash (soft-delete, recoverable). Use `recover_trace` to restore it.

Call `get_instructions` first in any new session — it returns a live reference guide covering Trace types, field definitions, filtering options, and tool usage, with the active Cortex's name and purpose already filled in.
Call `get_instructions` first in any new agent session for concise Markdown guidance. Use `cortex_usage` when a client needs structured JSON context. MCP tool discovery and each tool's schema remain the authoritative callable tool reference.

### stdio (Claude Desktop, Claude Code, any MCP client)

Expand Down Expand Up @@ -805,7 +808,7 @@ Noema supports three access patterns, depending on what tooling an agent has ava

### MCP (preferred)

Connect via `noema serve` and use the MCP tools. Call `get_instructions` at the start of a session for a live reference guide — it includes the Cortex name, purpose, Trace type definitions, and a full tool reference. Changes are indexed immediately; no manual sync needed.
Connect via `noema serve` and use the MCP tools. Call `get_instructions` at the start of an agent session for concise Markdown guidance; call `cortex_usage` for structured JSON context such as Cortex identity, trace semantics, startup preference retrieval, and runtime posture. Use MCP tool discovery for the full callable tool reference. Changes are indexed immediately; no manual sync needed.

### `noema` binary

Expand Down
17 changes: 17 additions & 0 deletions internal/cortex/actor.go
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,23 @@ func (c *Cortex) UpdateAs(id string, actor ReadActor) error {
return c.bumpModifyCount(id)
}

// SetTraceTagsAs is the actor-aware counterpart to SetTraceTags. It keeps
// metadata cleanup on the narrow tag mutation path while preserving the same
// agent modify-count signal used by UpdateAs for mutable tiers.
func (c *Cortex) SetTraceTagsAs(id string, tags []string, actor ReadActor) error {
row, err := c.Get(id)
if err != nil {
return err
}
if err := c.SetTraceTags(id, tags); err != nil {
return err
}
if actor != ActorAgent || row.Tier == trace.TierLong {
return nil
}
return c.bumpModifyCount(id)
}

// DefaultSearchHitTopN is how many of a search's top-ranked results bump
// search_hit_count when an agent runs the query. Auto-injection providers
// (Hermes-style) typically fit 1–3 trace summaries into their memory
Expand Down
122 changes: 122 additions & 0 deletions internal/cortex/cortex.go
Original file line number Diff line number Diff line change
Expand Up @@ -1135,11 +1135,15 @@ they keep the database in sync automatically:

| Tool | Purpose |
|---|---|
| ` + "`get_instructions`" + ` | Concise Markdown guidance for agent use of this Cortex |
| ` + "`cortex_usage`" + ` | Structured JSON context for MCP clients |
| ` + "`list_traces`" + ` | List traces with optional type/author/tag/origin filters |
| ` + "`get_trace`" + ` | Fetch full content of a trace by ID |
| ` + "`create_trace`" + ` | Create a new trace (supports derived_from, origin) |
| ` + "`update_trace`" + ` | Update fields of an existing trace |
| ` + "`append_trace`" + ` | Append content to a trace body (fire-and-forget) |
| ` + "`set_trace_tags`" + ` | Replace retrieval tags without touching title, body, type, or lineage |
| ` + "`append_trace_tags`" + ` | Add retrieval tags idempotently without touching title, body, type, or lineage |
| ` + "`search_traces`" + ` | Search across titles and bodies. ` + "`mode`" + `: ` + "`lexical`" + ` (FTS5, default), ` + "`semantic`" + ` (embedding similarity), or ` + "`hybrid`" + ` (RRF fusion). Semantic/hybrid need a configured ` + "`search:`" + ` block; they fall back to lexical otherwise |
| ` + "`find_similar_traces`" + ` | Surface traces related to one you have in hand. Default ranks by BM25 vocabulary overlap; ` + "`mode=semantic`" + `/` + "`hybrid`" + ` ranks by embedding similarity to the source trace's own vector |
| ` + "`archive_trace`" + ` | Archive a trace |
Expand Down Expand Up @@ -2132,6 +2136,54 @@ func (c *Cortex) UpdateFromFile(id string) error {
return c.updateFromFile(id, false)
}

// SetTraceTags replaces a trace's retrieval tags without mutating immutable
// trace fields such as updated_at or content_hash. Tags live outside the
// guarded traces row, so this remains legal for long-tier traces while still
// keeping the tag index, FTS, file frontmatter, and federation event log in
// sync.
func (c *Cortex) SetTraceTags(id string, tags []string) error {
if err := c.CheckSourceLock(id); err != nil {
return err
}
r, err := c.Get(id)
if err != nil {
return err
}
path := c.filePath(r)
t, err := trace.ParseFile(path)
if err != nil {
return err
}

t.Tags = tags
t.Tier = r.Tier
t.Updated = r.UpdatedAt
t.ContentHash = r.ContentHash
t.SourceLocked = r.SourceLocked
t.SourceHash = r.SourceHash
if err := t.WritePreservingUpdated(path); err != nil {
return fmt.Errorf("rewriting trace file tags: %w", err)
}

tx, err := c.DB.Begin()
if err != nil {
return err
}
defer tx.Rollback()

if err := replaceTraceTagsTx(tx, id, tags); err != nil {
return err
}
if err := upsertFTS(tx, id, r.Title, t.Body, tags); err != nil {
return err
}
now := time.Now().UTC().Format(time.RFC3339)
if err := c.emitEvent(tx, event.ActionTagUpdate, id, now, marshalTagData(tags)); err != nil {
return err
}
return tx.Commit()
}

func (c *Cortex) updateFromFile(id string, rewriteFile bool) error {
if err := c.CheckSourceLock(id); err != nil {
return err
Expand Down Expand Up @@ -3090,6 +3142,8 @@ func (c *Cortex) ReplayEvent(e event.Event) error {
replayErr = c.replayCreate(e)
case event.ActionUpdate:
replayErr = c.replayUpdate(e)
case event.ActionTagUpdate:
replayErr = c.replayTagUpdate(e)
case event.ActionArchive:
replayErr = c.replayArchive(e)
case event.ActionUnarchive:
Expand Down Expand Up @@ -3346,6 +3400,52 @@ func (c *Cortex) replayUpdate(e event.Event) error {
return tx.Commit()
}

func (c *Cortex) replayTagUpdate(e event.Event) error {
var data struct {
Tags []string `json:"tags"`
}
if err := json.Unmarshal(e.Data, &data); err != nil {
return fmt.Errorf("parsing tag_update event data: %w", err)
}

r, err := c.Get(e.TraceID)
if err != nil {
return fmt.Errorf("trace %s not found for replay tag_update: %w", e.TraceID, err)
}
path := c.filePath(r)
t, err := trace.ParseFile(path)
if err != nil {
return err
}

t.Tags = data.Tags
t.Tier = r.Tier
t.Updated = r.UpdatedAt
t.ContentHash = r.ContentHash
t.SourceLocked = r.SourceLocked
t.SourceHash = r.SourceHash
if err := t.WritePreservingUpdated(path); err != nil {
return fmt.Errorf("rewriting replayed trace tags: %w", err)
}

tx, err := c.DB.Begin()
if err != nil {
return err
}
defer tx.Rollback()

if err := replaceTraceTagsTx(tx, e.TraceID, data.Tags); err != nil {
return err
}
if err := upsertFTS(tx, e.TraceID, r.Title, t.Body, data.Tags); err != nil {
return err
}
if err := event.Append(tx, &e); err != nil {
return err
}
return tx.Commit()
}

// lastMutationEvent returns the most recent create or update event from the
// given list, which is assumed to be in chronological order.
func lastMutationEvent(events []event.Event) *event.Event {
Expand Down Expand Up @@ -4048,6 +4148,16 @@ func marshalTraceData(t *trace.Trace) json.RawMessage {
return data
}

func marshalTagData(tags []string) json.RawMessage {
payload := struct {
Tags []string `json:"tags"`
}{
Tags: tags,
}
data, _ := json.Marshal(payload)
return data
}

// ftsTagText joins a trace's tags into one space-delimited string for
// FTS5 indexing. The porter/ascii tokenizer splits on whitespace and
// indexes each tag as an independent token, so tagging a trace with
Expand Down Expand Up @@ -4080,6 +4190,18 @@ func upsertFTS(tx *sql.Tx, id, title, body string, tags []string) error {
return insertFTS(tx, id, title, body, tags)
}

func replaceTraceTagsTx(tx *sql.Tx, id string, tags []string) error {
if _, err := tx.Exec(`DELETE FROM trace_tags WHERE trace_id = ?`, id); err != nil {
return err
}
for _, tag := range tags {
if _, err := tx.Exec(`INSERT INTO trace_tags (trace_id, tag) VALUES (?, ?)`, id, tag); err != nil {
return err
}
}
return nil
}

// ensureFTSSchema checks that traces_fts has the four expected columns
// (id, title, body, tags) and rebuilds the virtual table if the tags
// column is missing. Intended for pre-v1 cortexes that were opened by
Expand Down
101 changes: 101 additions & 0 deletions internal/cortex/cortex_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (
"encoding/json"
"os"
"path/filepath"
"reflect"
"strings"
"testing"
"time"
Expand Down Expand Up @@ -1786,6 +1787,106 @@ func TestUpdateEmitsEvent(t *testing.T) {
}
}

func TestSetTraceTags_LongTierPreservesImmutableFields(t *testing.T) {
cx := setup(t)

tr := trace.New("Long tag metadata", "note", "agent", []string{"old"}, "Body about a durable thing.")
tr.Tier = trace.TierLong
if err := cx.Add(tr); err != nil {
t.Fatalf("Add: %v", err)
}
before, err := cx.Get(tr.ID)
if err != nil {
t.Fatalf("Get before: %v", err)
}

if err := cx.SetTraceTags(tr.ID, []string{"curated", "retrieval"}); err != nil {
t.Fatalf("SetTraceTags: %v", err)
}

after, err := cx.Get(tr.ID)
if err != nil {
t.Fatalf("Get after: %v", err)
}
if after.UpdatedAt != before.UpdatedAt {
t.Fatalf("UpdatedAt changed: got %q, want %q", after.UpdatedAt, before.UpdatedAt)
}
if after.ContentHash != before.ContentHash {
t.Fatalf("ContentHash changed: got %q, want %q", after.ContentHash, before.ContentHash)
}
if !reflect.DeepEqual(after.Tags, []string{"curated", "retrieval"}) {
t.Fatalf("tags = %v, want [curated retrieval]", after.Tags)
}

parsed, err := trace.ParseFile(cx.TraceFile(tr.ID, false))
if err != nil {
t.Fatalf("ParseFile: %v", err)
}
if parsed.Updated != before.UpdatedAt {
t.Fatalf("file updated changed: got %q, want %q", parsed.Updated, before.UpdatedAt)
}
if !reflect.DeepEqual(parsed.Tags, []string{"curated", "retrieval"}) {
t.Fatalf("file tags = %v, want [curated retrieval]", parsed.Tags)
}

rows, err := cx.Search("curated", cortex.ListOptions{})
if err != nil {
t.Fatalf("Search: %v", err)
}
if len(rows) != 1 || rows[0].ID != tr.ID {
t.Fatalf("search by new tag returned %+v, want %s", rows, tr.ID)
}

events, err := cx.Events(tr.ID)
if err != nil {
t.Fatalf("Events: %v", err)
}
if got := events[len(events)-1].Action; got != event.ActionTagUpdate {
t.Fatalf("last event action = %q, want %q", got, event.ActionTagUpdate)
}
}

func TestReplayTagUpdate_LongTierPreservesImmutableFields(t *testing.T) {
cx := setup(t)

tr := trace.New("Remote long tag metadata", "note", "agent", []string{"old"}, "Body.")
tr.Tier = trace.TierLong
if err := cx.Add(tr); err != nil {
t.Fatalf("Add: %v", err)
}
before, err := cx.Get(tr.ID)
if err != nil {
t.Fatalf("Get before: %v", err)
}

e := event.Event{
ID: event.NewULID(),
Action: event.ActionTagUpdate,
TraceID: tr.ID,
CortexID: "01J00000000000000000000000",
Origin: "remote",
Timestamp: time.Now().UTC().Format(time.RFC3339),
Data: json.RawMessage(`{"tags":["remote","curated"]}`),
}
if err := cx.ReplayEvent(e); err != nil {
t.Fatalf("ReplayEvent tag_update: %v", err)
}

after, err := cx.Get(tr.ID)
if err != nil {
t.Fatalf("Get after: %v", err)
}
if after.UpdatedAt != before.UpdatedAt {
t.Fatalf("UpdatedAt changed: got %q, want %q", after.UpdatedAt, before.UpdatedAt)
}
if after.ContentHash != before.ContentHash {
t.Fatalf("ContentHash changed: got %q, want %q", after.ContentHash, before.ContentHash)
}
if !reflect.DeepEqual(after.Tags, []string{"curated", "remote"}) {
t.Fatalf("tags = %v, want [curated remote]", after.Tags)
}
}

func TestEventsSince(t *testing.T) {
cx := setup(t)

Expand Down
2 changes: 1 addition & 1 deletion internal/cortex/signing.go
Original file line number Diff line number Diff line change
Expand Up @@ -132,7 +132,7 @@ func (c *Cortex) checkReplaySourceLock(e event.Event) error {
return nil
}
switch e.Action {
case event.ActionUpdate, event.ActionTrash, event.ActionPurge:
case event.ActionUpdate, event.ActionTagUpdate, event.ActionTrash, event.ActionPurge:
default:
return nil // create/archive/vote/tier/etc. don't overwrite locked content
}
Expand Down
1 change: 1 addition & 0 deletions internal/event/event.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ type Action string
const (
ActionCreate Action = "create"
ActionUpdate Action = "update"
ActionTagUpdate Action = "tag_update"
ActionArchive Action = "archive"
ActionUnarchive Action = "unarchive"
ActionTrash Action = "trash"
Expand Down
Loading
Loading