mirror of
https://github.com/mudler/LocalAI.git
synced 2026-11-06 12:33:16 -05:00
A person can now choose four save levels on the Memory tab: Save it and tell me, Ask me first (the default), Always confirm and Never. Always confirm is like ask, and nothing can loosen it: no agent or messaging setting overrides it, and the model cannot undo or forget a note by itself (revert_change is refused, and the person uses the Undo). One function, effectiveSaveLevel, combines the rules. The strictest wins, in the order auto, ask, always, never. The inputs are the level of the person, the rule of the saved agent, the messaging row and the chat. A table test covers every combination. A saved agent has an optional memory_save field: always or never. It can only be stricter than the person, so auto is not offered and the API refuses it. A write without the field keeps the stored rule, and use_mine clears it. It is stored by both stores and is part of an agent export and import. The agent form shows three cards under When it wants to save a note. The new row In messaging apps (messaging_save on GET and PUT /api/chats/memory) takes ask (the default), paired_private or never. With paired_private a note is saved at once only when the sender is paired and the chat is one-to-one, and it goes to the notes of that conversation, with an Undo. The limits stay: five notes a reply, the size limit, and a note that looks like a secret still waits. The row cannot loosen the level of the person when that is always or never. A setting that cannot be read reads as ask. The connector turn now carries whether the chat is one-to-one and whether the sender is paired, in the turn grant and in the worker JSON. Both read as false when unknown. The Telegram, Slack and Discord transports already set Direct; new specs pin it for private, group and supergroup chats, direct messages, channels, group conversations and threads. A note from a messaging chat goes to the conversation first. It reaches the notes of the agent or of the person only from a paired sender, only when the memory choice of the agent allows that scope, and only after that sender confirms it. Under Always confirm only the paired sender who asked can confirm. An agent whose memory is None still saves nothing. The script of the confirm-in-chat specs now proposes a note for the conversation, because a sender who is only listed cannot write to the notes of the person. Assisted-by: Claude:claude-sonnet-5-5
408 lines
16 KiB
Go
408 lines
16 KiB
Go
package studio
|
|
|
|
import (
|
|
"context"
|
|
"slices"
|
|
|
|
"github.com/mudler/LocalAI/core/services/studio/agents"
|
|
"github.com/mudler/LocalAI/core/services/studio/builtins"
|
|
"github.com/mudler/LocalAI/core/services/studio/contextfiles"
|
|
"github.com/mudler/LocalAI/core/services/studio/session"
|
|
"github.com/mudler/LocalAI/pkg/addons/contract"
|
|
)
|
|
|
|
// Memory is the short notes the assistant reads before it answers and may
|
|
// save. It is part of the host. The notes are plain Markdown files of the
|
|
// person's home:
|
|
//
|
|
// user Memory/index.md every chat
|
|
// agent Memory/agents/<agent>/index.md chats of one saved agent
|
|
// conversation Memory/channels/<binding>-<hash>/index.md one messaging conversation
|
|
//
|
|
// The host puts the three indexes in the prompt (40 lines and 2 KiB each, 6 KiB
|
|
// together, wrapped as data the person saved), serves the topic files with
|
|
// context_read, ships the two skills that teach the model to read and to save,
|
|
// and applies the save level (ask, auto, never). Saving is the memory kind of
|
|
// propose_change; the grant memory.write and the rules of changemode.go decide
|
|
// who may.
|
|
//
|
|
// The code that does this is behind MemoryProvider so that a later change can
|
|
// let an add-on serve the same job. The built-in provider is the reference.
|
|
//
|
|
// Until the add-on "memory" is retired its manifest still declares the same
|
|
// index files and skills. The built-in wins: its declarations are ignored while
|
|
// the host serves memory (see addonContextSources and addonSkillsFor), so
|
|
// nothing loads twice. The add-on stays installed and harmless.
|
|
|
|
// MemoryAddonID is the add-on that used to make notes reach the prompt.
|
|
const MemoryAddonID = contract.LegacyMemoryAddonID
|
|
|
|
// MemoryProvider is what serves the notes of a chat. The host asks it for the
|
|
// files the prompt carries and the skills that teach the model; it keeps every
|
|
// gate (the save level, the grants, the messaging rules, Off) to itself, so a
|
|
// provider cannot loosen one.
|
|
type MemoryProvider interface {
|
|
// ID is "builtin" for the host's own, or the ID of an add-on.
|
|
ID() string
|
|
// Sources are the index files of the notes, widest scope first.
|
|
Sources() []contextfiles.Source
|
|
// Skills are the names of the skills that teach the model to read and to
|
|
// save notes.
|
|
Skills() []string
|
|
}
|
|
|
|
// BuiltinMemoryID is the ID of the host's own provider.
|
|
const BuiltinMemoryID = "builtin"
|
|
|
|
type builtinMemory struct{}
|
|
|
|
func (builtinMemory) ID() string { return BuiltinMemoryID }
|
|
|
|
// Sources are the same three index files, with the same ID, that the add-on
|
|
// declared, so the block of the prompt does not change.
|
|
func (builtinMemory) Sources() []contextfiles.Source {
|
|
return []contextfiles.Source{
|
|
{ID: MemoryAddonID, Scope: contextfiles.ScopeUser, Path: "Memory/index.md"},
|
|
{ID: MemoryAddonID, Scope: contextfiles.ScopeAgent, Path: "Memory/agents/{agent}/index.md"},
|
|
{ID: MemoryAddonID, Scope: contextfiles.ScopeChannel, Path: "Memory/channels/{channel}/index.md"},
|
|
}
|
|
}
|
|
|
|
func (builtinMemory) Skills() []string { return []string{"memory", "memory-check-own-files"} }
|
|
|
|
// memorySkill reports whether sk is one of the built-in memory skills.
|
|
func (s *Service) memorySkill(sk string) bool { return slices.Contains(s.memory.Skills(), sk) }
|
|
|
|
// legacyMemory is what the add-on "memory" is on this server for a user.
|
|
type legacyMemory struct {
|
|
// Installed is true when the user can see the add-on.
|
|
Installed bool
|
|
// Running is true when it is enabled and running.
|
|
Running bool
|
|
}
|
|
|
|
// legacyMemoryOf finds the add-on among the sets that declare context files.
|
|
func legacyMemoryOf(sets []AddonContextSet) legacyMemory {
|
|
for _, set := range sets {
|
|
if set.AddonID == MemoryAddonID {
|
|
return legacyMemory{Installed: true, Running: set.Running}
|
|
}
|
|
}
|
|
return legacyMemory{}
|
|
}
|
|
|
|
// The reasons memory is off for a chat. The code is stable; a client words it.
|
|
const (
|
|
// MemoryOffUser: the person switched memory off for every chat, or
|
|
// disabled the Memory add-on and never chose.
|
|
MemoryOffUser = "memory_off_user"
|
|
// MemoryOffChat: this chat is private, or switched the Memory add-on off.
|
|
MemoryOffChat = "memory_off_chat"
|
|
// MemoryOffAgent: the saved agent of the chat turns memory off.
|
|
MemoryOffAgent = "memory_off_agent"
|
|
// MemoryOffFiles: the files are off on this server, and notes are files.
|
|
MemoryOffFiles = "memory_off_files"
|
|
)
|
|
|
|
// MemoryState says whether memory works for a chat, and when it does not,
|
|
// which level of the chain turned it off. The narrowest level wins: the chat,
|
|
// then the agent, then the person, then the server.
|
|
type MemoryState struct {
|
|
On bool
|
|
// Code is one of the MemoryOff constants, empty when On.
|
|
Code string
|
|
// By is who holds the switch that is off: "chat", "agent", "user" or
|
|
// "server".
|
|
By string
|
|
// Field names the field of the agent form that holds the switch, for the
|
|
// level "agent": "memory" (the choice None) or "tools:memory" (the old
|
|
// switch of the group).
|
|
Field string
|
|
}
|
|
|
|
func memoryOff(code, by string) MemoryState { return MemoryState{Code: code, By: by} }
|
|
|
|
// memoryState resolves the switch of a chat from what the host knows. legacy
|
|
// is the Memory add-on, for the person who never chose.
|
|
func (s *Service) memoryState(ctx context.Context, rec session.ChatRecord, legacy legacyMemory) MemoryState {
|
|
switch {
|
|
case slices.Contains(rec.BuiltinsOff, builtins.GroupMemory):
|
|
// A private chat.
|
|
return memoryOff(MemoryOffChat, "chat")
|
|
case slices.Contains(rec.AddonsOff, MemoryAddonID):
|
|
// The chat switched the Memory add-on off: the same switch, in the
|
|
// words it had before memory was built in.
|
|
return memoryOff(MemoryOffChat, "chat")
|
|
case s.agentMemory(ctx, rec.Principal, rec.AgentID) == agents.MemoryNone:
|
|
// The agent's choice None: it reads and saves nothing.
|
|
st := memoryOff(MemoryOffAgent, "agent")
|
|
st.Field = "memory"
|
|
return st
|
|
case slices.Contains(s.agentOff(ctx, rec), builtins.GroupMemory):
|
|
st := memoryOff(MemoryOffAgent, "agent")
|
|
st.Field = "tools:" + builtins.GroupMemory
|
|
return st
|
|
case !s.memoryUserOn(ctx, rec.Principal, legacy):
|
|
return memoryOff(MemoryOffUser, "user")
|
|
case !s.d.Files.Available():
|
|
return memoryOff(MemoryOffFiles, "server")
|
|
}
|
|
return MemoryState{On: true}
|
|
}
|
|
|
|
// memoryUserOn is the person's own switch. Without a stored answer memory is
|
|
// on, but for a person who disabled the Memory add-on to stop it: the disabled
|
|
// add-on keeps meaning "off" until they choose. A store that cannot answer
|
|
// reads as off: a failure never reads notes the person may have switched off.
|
|
func (s *Service) memoryUserOn(ctx context.Context, user string, legacy legacyMemory) bool {
|
|
if s.d.UserSetting != nil {
|
|
v, ok, err := s.d.UserSetting(ctx, principal(user), HostSettingsID, MemoryEnabledKey)
|
|
switch {
|
|
case err != nil:
|
|
s.log.Warn("studio: the memory switch could not be read, memory stays off for this turn", "error", err)
|
|
return false
|
|
case ok && v == MemoryOn:
|
|
return true
|
|
case ok && v == MemoryOff:
|
|
return false
|
|
}
|
|
}
|
|
return !legacy.Installed || legacy.Running
|
|
}
|
|
|
|
// The storage of the host settings of a person. They sit in the same store as
|
|
// the settings of the add-ons, under an ID that no add-on can have (an add-on
|
|
// ID starts with a letter).
|
|
const (
|
|
// HostSettingsID is the owner of the host's own per-person settings.
|
|
HostSettingsID = "_host"
|
|
// MemoryEnabledKey holds MemoryOn or MemoryOff.
|
|
MemoryEnabledKey = "memory"
|
|
// MemorySaveKey holds the save level: ask, auto, always or never.
|
|
MemorySaveKey = "memory-save-mode"
|
|
// MemorySaveMessagingKey holds the messaging row: ask, paired_private or
|
|
// never.
|
|
MemorySaveMessagingKey = "memory-save-messaging"
|
|
)
|
|
|
|
// The values of MemoryEnabledKey.
|
|
const (
|
|
MemoryOn = "on"
|
|
MemoryOff = "off"
|
|
)
|
|
|
|
// memoryReads says whether the chat reads the notes. A saved agent with a
|
|
// choice of its own (agents.Memory) reads what the choice names; one saved
|
|
// before the choice ("as before") reads them only when it names the Memory
|
|
// add-on. A chat without an agent reads them.
|
|
func (s *Service) memoryReads(ctx context.Context, rec session.ChatRecord, sc *scope, legacy legacyMemory) bool {
|
|
if !s.memoryState(ctx, rec, legacy).On {
|
|
return false
|
|
}
|
|
return sc == nil || sc.memory != "" || sc.addons[MemoryAddonID]
|
|
}
|
|
|
|
// agentMemory is the choice of notes of a saved agent: one of the agents.Memory
|
|
// values, or empty for a chat without an agent, an agent that cannot be read
|
|
// and an agent saved before the choice ("as before").
|
|
func (s *Service) agentMemory(ctx context.Context, user, agentID string) string {
|
|
if agentID == "" || s.d.Agents == nil {
|
|
return ""
|
|
}
|
|
ag, err := s.d.Agents.Get(ctx, user, agentID)
|
|
if err != nil {
|
|
return ""
|
|
}
|
|
return ag.Memory
|
|
}
|
|
|
|
// agentMemorySave is the save rule of a saved agent (agents.MemorySaveAlways,
|
|
// agents.MemorySaveNever), or empty for the person's setting and for a chat
|
|
// without an agent. An agent that cannot be read fails closed: the rule is
|
|
// always, because the stored rule may have been a stricter one.
|
|
func (s *Service) agentMemorySave(ctx context.Context, user, agentID string) string {
|
|
if agentID == "" || s.d.Agents == nil {
|
|
return ""
|
|
}
|
|
ag, err := s.d.Agents.Get(ctx, user, agentID)
|
|
if err != nil {
|
|
return agents.MemorySaveAlways
|
|
}
|
|
return ag.MemorySave
|
|
}
|
|
|
|
// memorySourcesFor narrows the index files of the notes to what a choice of
|
|
// the agent reads. The notes of a messaging conversation stay with every
|
|
// choice but None: they belong to the chat, and a conversation outside a
|
|
// messaging app has none. Empty (as before) and Mine and its own read all.
|
|
func memorySourcesFor(all []contextfiles.Source, choice string) []contextfiles.Source {
|
|
var keep func(contextfiles.Scope) bool
|
|
switch choice {
|
|
case agents.MemoryNone:
|
|
return nil
|
|
case agents.MemoryOwn:
|
|
keep = func(sc contextfiles.Scope) bool { return sc != contextfiles.ScopeUser }
|
|
case agents.MemoryMine:
|
|
keep = func(sc contextfiles.Scope) bool { return sc != contextfiles.ScopeAgent }
|
|
default:
|
|
return all
|
|
}
|
|
var out []contextfiles.Source
|
|
for _, src := range all {
|
|
if keep(src.Scope) {
|
|
out = append(out, src)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// memoryWriteScopes are the scopes a chat may save a note to, enforced by the
|
|
// host: Mine and its own and Its own only save to the agent, Mine saves to the
|
|
// person, None saves nowhere. It is nil when the model chooses, which is a
|
|
// chat without an agent and an agent saved before the choice ("as before").
|
|
func (s *Service) memoryWriteScopes(ctx context.Context, c builtins.Chat) []string {
|
|
switch s.agentMemory(ctx, c.Actor.Principal, c.AgentID) {
|
|
case agents.MemoryOwn, agents.MemoryMineAndOwn:
|
|
return []string{string(contract.ScopeAgent)}
|
|
case agents.MemoryMine:
|
|
return []string{string(contract.ScopeUser)}
|
|
case agents.MemoryNone:
|
|
return []string{}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// memoryChatScopes are the scopes a chat may save a note to. A chat outside a
|
|
// messaging app has what memoryWriteScopes says. A messaging chat saves to its
|
|
// own conversation first, and may also save to the agent or the person only
|
|
// when the sender is paired with the bot and the agent's choice allows that
|
|
// scope (with no choice, "as before", the model may name either). An agent
|
|
// with the choice None saves nowhere. It is never nil for a messaging chat.
|
|
func (s *Service) memoryChatScopes(ctx context.Context, c builtins.Chat) []string {
|
|
fixed := s.memoryWriteScopes(ctx, c)
|
|
if c.Origin != session.OriginConnector {
|
|
return fixed
|
|
}
|
|
if fixed != nil && len(fixed) == 0 {
|
|
return fixed
|
|
}
|
|
out := []string{string(contract.ScopeChannel)}
|
|
if _, paired := s.messagingFacts(c.Actor.ChatID); !paired {
|
|
return out
|
|
}
|
|
if fixed == nil {
|
|
return append(out, string(contract.ScopeUser), string(contract.ScopeAgent))
|
|
}
|
|
return append(out, fixed...)
|
|
}
|
|
|
|
// memorySkillsOff adds the memory skills to the skills a chat has switched off
|
|
// when the chat does not read the notes: a skill that says "read your notes"
|
|
// has no use then, and the model must not look for them.
|
|
func (s *Service) memorySkillsOff(ctx context.Context, rec session.ChatRecord, sc *scope, off []string) []string {
|
|
legacy := legacyMemoryOf(s.addonContextSets(ctx, rec.Principal))
|
|
if s.memoryReads(ctx, rec, sc, legacy) {
|
|
return off
|
|
}
|
|
return append(slices.Clone(off), s.memory.Skills()...)
|
|
}
|
|
|
|
// memoryWritable says whether a chat may propose a memory note: memory is on
|
|
// for it. A chat that does not read the notes (a saved agent that does not name
|
|
// the Memory add-on) still proposes, as before: the grant decides that.
|
|
func (s *Service) memoryWritable(ctx context.Context, c builtins.Chat) bool {
|
|
rec := session.ChatRecord{Principal: c.Actor.Principal, ID: c.Actor.ChatID}
|
|
if got, err := s.d.Store.GetChat(ctx, c.Actor.Principal, c.Actor.ChatID); err == nil {
|
|
rec = got
|
|
}
|
|
legacy := legacyMemoryOf(s.addonContextSets(ctx, c.Actor.Principal))
|
|
return s.memoryState(ctx, rec, legacy).On && !slices.Contains(c.GroupsOff, builtins.GroupMemory)
|
|
}
|
|
|
|
// memoryOffLine is the one line the model reads while memory is off for a
|
|
// chat: it hears that nothing is read or saved and what to say when asked.
|
|
func memoryOffLine(st MemoryState) string {
|
|
where := "in the user's settings"
|
|
switch st.By {
|
|
case "chat":
|
|
where = "for this chat, which is private"
|
|
case "agent":
|
|
where = "for this agent"
|
|
}
|
|
return "Memory is off " + where + ", so notes are neither read nor saved. If asked to remember something, say that memory is off and that the user can turn it on again; do not try to save it any other way."
|
|
}
|
|
|
|
// MemoryReason is a memory state worded for a person, with where to fix it.
|
|
type MemoryReason struct {
|
|
On bool `json:"on"`
|
|
// Code is a stable name of the level that is off: memory_off_chat,
|
|
// memory_off_agent, memory_off_user or memory_off_files. Empty when on.
|
|
Code string `json:"code,omitempty"`
|
|
// By is who holds that switch: chat, agent, user or server.
|
|
By string `json:"blocked_by,omitempty"`
|
|
// Reason is an English sentence for logs and tests.
|
|
Reason string `json:"reason,omitempty"`
|
|
// Fix says where to turn it on again.
|
|
Fix *Fix `json:"fix,omitempty"`
|
|
}
|
|
|
|
// The fixes of a memory switch that is off.
|
|
const (
|
|
// FixOpenMemorySettings points at the Memory tab of the account settings.
|
|
FixOpenMemorySettings = "open_memory_settings"
|
|
// FixOpenToolsMenu points at the Tools menu of the chat.
|
|
FixOpenToolsMenu = "open_tools_menu"
|
|
)
|
|
|
|
// memoryReason words a state.
|
|
func memoryReason(st MemoryState, rec session.ChatRecord) MemoryReason {
|
|
out := MemoryReason{On: st.On, Code: st.Code, By: st.By}
|
|
switch st.Code {
|
|
case MemoryOffChat:
|
|
out.Reason = "Memory is off in this chat: it is a private chat."
|
|
out.Fix = &Fix{Kind: FixOpenToolsMenu, Target: "memory"}
|
|
case MemoryOffAgent:
|
|
out.Reason = "Memory is off for the agent of this chat."
|
|
out.Fix = &Fix{Kind: FixOpenAgent, Target: rec.AgentID, Field: st.Field}
|
|
case MemoryOffUser:
|
|
out.Reason = "Memory is off in your settings."
|
|
out.Fix = &Fix{Kind: FixOpenMemorySettings, Target: "memory"}
|
|
case MemoryOffFiles:
|
|
out.Reason = "Memory needs files, and files are off on this server."
|
|
out.Fix = &Fix{Kind: FixOpenServerSetting, Target: "files"}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// ChatMemory says whether memory works in a chat and, when it does not, which
|
|
// level turned it off.
|
|
func (s *Service) ChatMemory(ctx context.Context, rec session.ChatRecord) MemoryReason {
|
|
legacy := legacyMemoryOf(s.addonContextSets(ctx, rec.Principal))
|
|
return memoryReason(s.memoryState(ctx, rec, legacy), rec)
|
|
}
|
|
|
|
// registerMemoryGroup puts memory in the Tools menu as one switch group. The
|
|
// group has no tool of its own to build: the host reads the switch when it
|
|
// builds the prompt, so a private chat has no notes in it and no context_read.
|
|
// Build only says whether the switch can work now, and why not.
|
|
func (s *Service) registerMemoryGroup() error {
|
|
if !s.d.Files.Available() {
|
|
return nil // notes are files: without them there is no switch to show
|
|
}
|
|
return s.builtins.Register(builtins.Tool{
|
|
ID: contextfiles.ToolName, Group: builtins.GroupMemory, Label: "Memory", External: true, Unlisted: true,
|
|
Description: "Read the notes of the user's memory.",
|
|
Build: func(ctx context.Context, c builtins.Chat) (session.Tool, bool, string) {
|
|
switch {
|
|
case !s.d.Files.Available():
|
|
return session.Tool{}, false, "memory needs files, and files are off on this server"
|
|
case c.SubAgent:
|
|
return session.Tool{}, false, "a sub-agent follows the memory of its parent chat"
|
|
case !s.memoryUserOn(ctx, c.Actor.Principal, legacyMemoryOf(s.addonContextSets(ctx, c.Actor.Principal))):
|
|
return session.Tool{}, false, "memory is off in your settings"
|
|
}
|
|
return session.Tool{}, true, ""
|
|
},
|
|
})
|
|
}
|