Files
LocalAI/core/services/studio/changemode.go
T
Ettore Di Giacinto 82b9984e06 feat(memory): serve the notes of memory from the host
The Memory add-on only declared three index files, two skills and a save
setting; the host did the rest. Without the add-on a memory proposal
worked but nothing was read back, and removing the add-on silently reset
the save level.

The host now loads the three indexes (user, agent, conversation) with
the same caps and data wrapping, serves context_read, ships the two
skills as built-in skills with the same text, and keeps the save level
as its own per-person setting (ask, auto, never; default ask). The code
sits behind a small MemoryProvider interface.

The add-on stays installed and harmless: its index files and skills are
ignored while the host serves memory, so nothing loads twice. A person
who chose a level on its page keeps it until they choose in the host
setting; auto still needs the add-on to run. A person who disabled the
add-on and never chose keeps memory off. Saved agents still read notes
only when their add-ons list names memory, and messaging chats still
ask before saving.

A person with no note and no add-on gets no extra block in the prompt.

Assisted-by: Claude:claude-sonnet-5-5
2026-11-04 17:25:40 +00:00

266 lines
9.0 KiB
Go

package studio
import (
"context"
"regexp"
"sync/atomic"
"github.com/mudler/LocalAI/core/services/studio/builtins"
"github.com/mudler/LocalAI/pkg/addons/contract"
)
// How a memory note is saved. The person chooses on the page of an add-on
// that declares the setting (a per-person setting that the host keeps). Every
// memory write is a proposal that the person confirms, and that stays the
// default.
//
// ask the model proposes, the person confirms (default)
// auto the host applies a note from the person's own chat at once, as a
// change set the person can undo, and the chat shows a note with Undo
// never the model gets no way to propose a memory note
//
// The setting only ever narrows or keeps the confirmation for anything that is
// not the person's own chat. The rules, in one place:
//
// - A connector chat keeps ask whatever the setting says: the sender of a
// message is not necessarily the person, and a stranger's message must
// never write memory unconfirmed.
// - An unattended run (a trigger or a job) has no propose tool at all.
// - A saved agent cannot widen the choice of the person: the agent's grants
// decide whether it may propose, the person's setting decides how the
// proposal is applied. An agent with its own memory.write grant still asks
// when the person said ask.
// - never applies to every origin: it is the person's choice, and refusing is
// the narrow side.
// - Only the memory kind reads the setting. Profile, skill, page and
// dashboard changes always ask.
//
// The setting belongs to an add-on, not to the host: an add-on declares a user
// setting with the role contract.UserSettingRoleMemorySave in its manifest,
// edits it on its own page, and the host finds it by that role. The host fixes
// the values and the default of the role, so a manifest cannot make the host
// more permissive. If several add-ons declare the role, the strictest answer
// wins (never, then ask, then auto), and an add-on that is not running does not
// count as auto.
// The values of the setting.
const (
SaveAsk = "ask"
SaveAuto = "auto"
SaveNever = "never"
)
// The limits of an automatic write. A write over a limit is not lost: it
// becomes a normal proposal that the person confirms.
const (
// MaxAutoWritesPerTurn is the most notes one turn saves without asking.
MaxAutoWritesPerTurn = 5
// MaxAutoWriteBytes is the largest note saved without asking.
MaxAutoWriteBytes = 4 << 10
)
// EffectiveSaveMode is the mode that applies to a write, from the stored
// choice of the person and the origin of the chat (contract.ChangeSource.Kind:
// "" for a chat the person watches). An unknown stored value counts as ask.
func EffectiveSaveMode(stored, origin string, subAgent bool) string {
switch stored {
case SaveNever:
return SaveNever
case SaveAuto:
if origin == "" && !subAgent {
return SaveAuto
}
}
return SaveAsk
}
// AddonUserSettingSet is the per-person settings one add-on declares, as the
// host rules see them.
type AddonUserSettingSet struct {
AddonID string
Settings []contract.UserSetting
// Running is true when the add-on can serve now: installed, enabled and
// running.
Running bool
}
// AddonUserSettingsLister is implemented by a broker that knows the per-person
// settings of the add-ons. It lists the installed add-ons that declare some
// and that the user can see. It is optional; a broker without it gives none,
// and the host asks.
type AddonUserSettingsLister interface {
AddonUserSettings(ctx context.Context, user string) ([]AddonUserSettingSet, error)
}
// strictness orders the modes: the larger one wins when add-ons disagree.
func strictness(mode string) int {
switch mode {
case SaveNever:
return 2
case SaveAsk:
return 1
}
return 0
}
// ValidSaveMode reports whether v is a value of the save level.
func ValidSaveMode(v string) bool { return v == SaveAsk || v == SaveAuto || v == SaveNever }
// legacySaveKey is the setting of the Memory add-on, read when the person has
// not chosen a level in the host's own setting.
const legacySaveKey = "save-mode"
// storedSaveMode reads the choice of the person. Anything that goes wrong
// reads as ask, the default: a failure never loosens the rule. The level is
// the host's own setting (HostSettingsID, MemorySaveKey). A person who never
// set it keeps the value they gave on the page of the Memory add-on; there auto
// counts only while the add-on runs, as it always did. Another add-on that
// declares the role can only make the answer stricter.
func (c *Changes) storedSaveMode(ctx context.Context, user string) string {
read := c.s.d.UserSetting
if read == nil {
return SaveAsk
}
lister, _ := c.s.d.AddonTools.(AddonUserSettingsLister)
var sets []AddonUserSettingSet
if lister != nil {
var err error
if sets, err = lister.AddonUserSettings(ctx, user); err != nil {
c.s.log.Warn("studio: the add-on settings could not be listed, asking for confirmation", "error", err)
return SaveAsk
}
}
hostMode, hostSet := "", false
v, ok, err := read(ctx, principal(user), HostSettingsID, MemorySaveKey)
switch {
case err != nil:
c.s.log.Warn("studio: the memory save setting could not be read, asking for confirmation", "error", err)
return SaveAsk
case ok && ValidSaveMode(v):
hostMode, hostSet = v, true
}
// The add-ons that declare the role: the strictest answer wins (never,
// then ask, then auto), an add-on that does not run does not count as
// auto. Once the person chose in the host's own setting, the Memory
// add-on no longer counts: its page is the old place to choose.
mode, found := SaveAuto, false
for _, set := range sets {
if hostSet && set.AddonID == MemoryAddonID {
continue
}
for _, d := range set.Settings {
if d.Role != contract.UserSettingRoleMemorySave {
continue
}
found = true
v, ok, err := read(ctx, principal(user), set.AddonID, d.Key)
switch {
case err != nil:
c.s.log.Warn("studio: the memory save setting could not be read, asking for confirmation", "addon", set.AddonID, "error", err)
v = SaveAsk
case !ok || !d.Allows(v):
v = SaveAsk
case v == SaveAuto && !set.Running:
v = SaveAsk
}
if strictness(v) > strictness(mode) {
mode = v
}
}
}
if !found {
mode = SaveAsk
if !hostSet {
// The Memory add-on was removed: what the person chose on its
// page still holds as far as it narrows. A looser value never
// comes back unasked.
lv, ok, err := read(ctx, principal(user), MemoryAddonID, legacySaveKey)
switch {
case err != nil:
c.s.log.Warn("studio: the memory save setting could not be read, asking for confirmation", "error", err)
case ok && lv == SaveNever:
mode = SaveNever
}
}
}
if hostSet {
if strictness(mode) > strictness(hostMode) && found {
return normalSaveMode(mode)
}
return hostMode
}
return normalSaveMode(mode)
}
func normalSaveMode(m string) string {
switch m {
case SaveAuto, SaveNever:
return m
}
return SaveAsk
}
// saveModeOf is the mode of a memory write made in the chat.
func (c *Changes) saveModeOf(ctx context.Context, ch builtins.Chat) string {
return EffectiveSaveMode(c.storedSaveMode(ctx, ch.Actor.Principal), ch.Origin, ch.SubAgent)
}
// autoTurn counts the automatic writes of one turn.
type autoTurn struct{ n atomic.Int32 }
// take reserves one automatic write of the turn.
func (t *autoTurn) take() bool {
if t == nil {
return false
}
for {
n := t.n.Load()
if n >= MaxAutoWritesPerTurn {
return false
}
if t.n.CompareAndSwap(n, n+1) {
return true
}
}
}
// give returns a reservation that was not used.
func (t *autoTurn) give() {
if t != nil {
t.n.Add(-1)
}
}
// secretLike matches text that looks like a credential. It is deliberately
// narrow: a note is short prose, and a hit only means the note is not saved
// without asking.
var secretLike = regexp.MustCompile(`(?i)-----BEGIN [A-Z ]*PRIVATE KEY-----|\b(sk|pk|rk)-[a-z0-9_-]{20,}|\bgh[pousr]_[A-Za-z0-9]{30,}|\bAKIA[0-9A-Z]{16}\b|\bxox[abprs]-[A-Za-z0-9-]{10,}|\beyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}|\b(password|passwd|secret|api[_ -]?key|token)\s*[:=]\s*\S{6,}`)
// autoFits says whether a note may be saved without asking, and when not, why
// (worded for the model).
func autoFits(content string, turn *autoTurn) (bool, string) {
switch {
case len(content) > MaxAutoWriteBytes:
return false, "the note is longer than a note that is saved at once"
case secretLike.MatchString(content):
return false, "the note looks like it holds a secret"
case !turn.take():
return false, "this turn already saved the most notes that are saved at once"
}
return true, ""
}
// memoryKindOf reports whether a kind is the memory kind.
func memoryKindOf(kind string) bool { return kind == string(contract.ChangeMemory) }
// withoutMemory removes the memory kind from a list.
func withoutMemory(kinds []contract.ChangeKind) []contract.ChangeKind {
out := make([]contract.ChangeKind, 0, len(kinds))
for _, k := range kinds {
if k != contract.ChangeMemory {
out = append(out, k)
}
}
return out
}