mirror of
https://github.com/mudler/LocalAI.git
synced 2026-11-06 12:33:16 -05:00
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
121 lines
4.8 KiB
Go
121 lines
4.8 KiB
Go
package studio
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"io"
|
|
|
|
"github.com/mudler/LocalAI/core/services/studio/contextfiles"
|
|
"github.com/mudler/LocalAI/core/services/studio/files"
|
|
"github.com/mudler/LocalAI/core/services/studio/proposals"
|
|
"github.com/mudler/LocalAI/core/services/studio/session"
|
|
"github.com/mudler/LocalAI/core/services/studio/workspace"
|
|
"github.com/mudler/LocalAI/pkg/addons/contract"
|
|
)
|
|
|
|
// homeReader reads the files of a user's home as the user, outside the fence
|
|
// of any chat. The loader names the path, never the model, so a scoped chat
|
|
// reads its own notes without gaining any access to the files.
|
|
type homeReader struct{ f *files.Service }
|
|
|
|
func (h homeReader) ReadFile(ctx context.Context, principal, rel string, limit int64) ([]byte, error) {
|
|
if !h.f.Available() {
|
|
return nil, contextfiles.ErrNotExist
|
|
}
|
|
abs, err := h.f.Abs(principal, rel)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
r, node, err := h.f.Store().Open(ctx, h.f.Actor(principal), abs)
|
|
if errors.Is(err, workspace.ErrNotFound) {
|
|
return nil, contextfiles.ErrNotExist
|
|
}
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer func() { _ = r.Close() }()
|
|
if node.Kind != workspace.KindFile {
|
|
return nil, contextfiles.ErrNotExist
|
|
}
|
|
return io.ReadAll(io.LimitReader(r, limit))
|
|
}
|
|
|
|
// ContextSources returns the registry of context files. A component that wants
|
|
// the host to load a small index of the user's files into the prompt registers
|
|
// a source here; nothing else is needed, and the source applies to every chat
|
|
// whatever started it.
|
|
func (s *Service) ContextSources() *contextfiles.Registry { return s.ctxfiles.Registry() }
|
|
|
|
// SetContextWriter plugs in the path that saves notes. Without one the prompt
|
|
// does not mention saving.
|
|
func (s *Service) SetContextWriter(w contextfiles.Writer) { s.ctxfiles.SetWriter(w) }
|
|
|
|
// contextSubject says whose notes a chat reads. Everything comes from the chat
|
|
// record: the model names a topic, never an agent or a channel. The channel
|
|
// folder is the one propose_change writes for the same chat, so a confirmed
|
|
// note is read back by the conversation that proposed it. saveTool is the tool
|
|
// the chat has to save a note, or empty.
|
|
func contextSubject(rec session.ChatRecord, saveTool string) contextfiles.Subject {
|
|
sub := contextfiles.Subject{Principal: rec.Principal, AgentID: rec.AgentID, SaveTool: saveTool}
|
|
if rec.Origin == session.OriginConnector {
|
|
sub.ChannelDir = proposals.ChannelDir(contract.ChangeSource{ID: rec.OriginID, Chat: rec.ID})
|
|
}
|
|
return sub
|
|
}
|
|
|
|
// contextTurn returns the agent profile and the context indexes for the prompt
|
|
// of a turn, and a key that changes when either does. Both texts are the same
|
|
// at each turn until a file changes, so the transcript does not grow and the
|
|
// harness is restarted only for a real change.
|
|
func (s *Service) contextTurn(ctx context.Context, rec session.ChatRecord, tools []session.Tool, extra []contextfiles.Source) (text, key string) {
|
|
return s.contextTurnFor(ctx, rec, tools, extra, false)
|
|
}
|
|
|
|
// contextTurnFor is contextTurn; omitEmpty leaves the header out while no
|
|
// index has text.
|
|
func (s *Service) contextTurnFor(ctx context.Context, rec session.ChatRecord, tools []session.Tool, extra []contextfiles.Source, omitEmpty bool) (text, key string) {
|
|
if !s.d.Files.Available() {
|
|
return "", ""
|
|
}
|
|
sub := contextSubject(rec, saveToolOf(tools))
|
|
sub.OmitEmpty = omitEmpty
|
|
profile, index := s.ctxfiles.Profile(ctx, sub), s.ctxfiles.Block(ctx, sub, extra...)
|
|
return joinBlocks(profile.Text, index.Text), profile.Key + index.Key
|
|
}
|
|
|
|
// saveToolOf is the tool a chat has to save a note, or empty. The prompt names
|
|
// it only when the chat has it.
|
|
func saveToolOf(tools []session.Tool) string {
|
|
if hasTool(tools, proposeChangeName) {
|
|
return proposeChangeName
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// contextTools is context_read, while a source applies to the chat.
|
|
func (s *Service) contextTools(rec session.ChatRecord, tools []session.Tool, extra []contextfiles.Source) []session.Tool {
|
|
if !s.d.Files.Available() || len(s.ctxfiles.Sources(extra...)) == 0 {
|
|
return nil
|
|
}
|
|
sub := contextSubject(rec, saveToolOf(tools))
|
|
return []session.Tool{{
|
|
Name: contextfiles.ToolName,
|
|
Description: contextfiles.ToolDescription,
|
|
Schema: json.RawMessage(`{"type":"object","properties":{` +
|
|
`"topic":{"type":"string","description":"The topic name or file name from a line of the index."},` +
|
|
`"scope":{"type":"string","enum":["user","agent","channel"],"description":"Where to look. Omit to search the channel, then the agent, then the user."}},` +
|
|
`"required":["topic"]}`),
|
|
Run: func(ctx context.Context, raw json.RawMessage) (string, error) {
|
|
var args struct {
|
|
Topic string `json:"topic"`
|
|
Scope string `json:"scope"`
|
|
}
|
|
if err := json.Unmarshal(raw, &args); err != nil {
|
|
return "", err
|
|
}
|
|
return s.ctxfiles.Detail(ctx, sub, contextfiles.Scope(args.Scope), args.Topic, extra...)
|
|
},
|
|
}}
|
|
}
|