Files
LocalAI/core/services/studio/memorysettings.go
T
Ettore Di Giacinto adbb71cfdf feat(memory): add Always confirm, an agent save rule and a messaging row
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
2026-11-04 19:45:08 +00:00

152 lines
5.5 KiB
Go

package studio
import (
"archive/zip"
"context"
"errors"
"fmt"
"io"
"path"
"strings"
"github.com/mudler/LocalAI/core/services/studio/contextfiles"
)
// ErrMemorySettings means the memory settings cannot be stored.
var ErrMemorySettings = errors.New("studio: the memory settings cannot be stored on this server")
// MemorySettings is the person's own memory choices, as the Memory tab shows
// them. The server keeps them with the person's other settings and every
// frontend reads the same answer.
type MemorySettings struct {
// Enabled is the switch in force: memory On or Off for every chat of the
// person. A chat, a messaging connection or an agent can only narrow it.
Enabled bool `json:"enabled"`
// EnabledSet is true once the person chose. Before that the switch is on,
// unless they had disabled the Memory add-on to stop memory.
EnabledSet bool `json:"enabled_set"`
// SaveMode is how a note is saved: ask, auto, always or never.
SaveMode string `json:"save_mode"`
// MessagingSave is how a note proposed in a messaging chat is saved: ask,
// paired_private or never. It never loosens always or never in SaveMode.
MessagingSave string `json:"messaging_save"`
// SaveModeSet is true once the person chose in this setting. Before that
// the value they gave on the page of the Memory add-on, if any, applies.
SaveModeSet bool `json:"save_mode_set"`
// Provider says what serves memory: "builtin".
Provider string `json:"provider"`
// Available is false when the server has no files: notes are files.
Available bool `json:"available"`
}
// MemoryUpdate is a change to the settings. A nil field is left alone.
type MemoryUpdate struct {
Enabled *bool
SaveMode *string
// MessagingSave is the messaging row: ask, paired_private or never.
MessagingSave *string
}
// MemorySettings reads the memory settings of a person.
func (s *Service) MemorySettings(ctx context.Context, user string) (MemorySettings, error) {
legacy := legacyMemoryOf(s.addonContextSets(ctx, user))
out := MemorySettings{Provider: s.memory.ID(), Available: s.d.Files.Available(), SaveMode: SaveAsk, MessagingSave: MessagingAsk}
out.Enabled = s.memoryUserOn(ctx, user, legacy)
if s.d.UserSetting != nil {
if v, ok, err := s.d.UserSetting(ctx, principal(user), HostSettingsID, MemoryEnabledKey); err == nil && ok && (v == MemoryOn || v == MemoryOff) {
out.EnabledSet = true
}
if v, ok, err := s.d.UserSetting(ctx, principal(user), HostSettingsID, MemorySaveKey); err == nil && ok && ValidSaveMode(v) {
out.SaveModeSet = true
}
}
out.SaveMode = s.changes.storedSaveMode(ctx, user)
out.MessagingSave = s.changes.storedMessagingSave(ctx, user)
return out, nil
}
// SetMemory stores the choices of a person. A value that is not one of the
// allowed ones is ErrInvalidSettings.
func (s *Service) SetMemory(ctx context.Context, user string, in MemoryUpdate) (MemorySettings, error) {
if in.SaveMode != nil && !ValidSaveMode(*in.SaveMode) {
return MemorySettings{}, fmt.Errorf("%w: save_mode must be ask, auto, always or never", ErrInvalidSettings)
}
if in.MessagingSave != nil && !ValidMessagingSave(*in.MessagingSave) {
return MemorySettings{}, fmt.Errorf("%w: messaging_save must be ask, paired_private or never", ErrInvalidSettings)
}
if (in.Enabled != nil || in.SaveMode != nil || in.MessagingSave != nil) && s.d.SetUserSetting == nil {
return MemorySettings{}, ErrMemorySettings
}
if in.Enabled != nil {
v := MemoryOff
if *in.Enabled {
v = MemoryOn
}
if err := s.d.SetUserSetting(ctx, principal(user), HostSettingsID, MemoryEnabledKey, v); err != nil {
return MemorySettings{}, fmt.Errorf("%w: %v", ErrMemorySettings, err)
}
}
if in.SaveMode != nil {
if err := s.d.SetUserSetting(ctx, principal(user), HostSettingsID, MemorySaveKey, *in.SaveMode); err != nil {
return MemorySettings{}, fmt.Errorf("%w: %v", ErrMemorySettings, err)
}
}
if in.MessagingSave != nil {
if err := s.d.SetUserSetting(ctx, principal(user), HostSettingsID, MemorySaveMessagingKey, *in.MessagingSave); err != nil {
return MemorySettings{}, fmt.Errorf("%w: %v", ErrMemorySettings, err)
}
}
return s.MemorySettings(ctx, user)
}
// ErrNoNotes means a scope has no notes to export.
var ErrNoNotes = errors.New("studio: there are no notes to export")
// ExportMemory writes the notes of a person as a zip file: the Markdown files
// of the memory folders, with their paths in the home. scope and key narrow it
// to one group, as the context files list names them (user; agent and the
// agent ID; channel and the folder name); empty exports all. It works while
// memory is off: the notes belong to the person.
func (s *Service) ExportMemory(ctx context.Context, user string, w io.Writer, scope, key string) (int, error) {
list, err := s.ListContextFiles(ctx, user)
if err != nil {
return 0, err
}
var paths []string
for _, g := range list.Groups {
if g.Source != MemoryAddonID {
continue
}
if scope != "" && (string(g.Scope) != scope || g.Key != key) {
continue
}
for _, f := range g.Files {
paths = append(paths, f.Path)
}
}
if len(paths) == 0 {
return 0, ErrNoNotes
}
zw := zip.NewWriter(w)
rd := homeReader{f: s.d.Files}
n := 0
for _, p := range paths {
data, err := rd.ReadFile(ctx, principal(user), p, 1<<20)
if err != nil {
if errors.Is(err, contextfiles.ErrNotExist) {
continue
}
return n, err
}
fw, err := zw.Create(strings.TrimPrefix(path.Clean(p), "/"))
if err != nil {
return n, err
}
if _, err := fw.Write(data); err != nil {
return n, err
}
n++
}
return n, zw.Close()
}