Ship Assistent disk persist, park LLM, and memory UI cleanup.
Split the extension into partials, persist chats on the data volume, park/warm the chat model around Generate, and drop dual raw/persona dump paths. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
+84
-6
@@ -155,6 +155,8 @@
|
|||||||
activeChatId: null,
|
activeChatId: null,
|
||||||
restoringChat: false,
|
restoringChat: false,
|
||||||
chatsPanelOpen: false,
|
chatsPanelOpen: false,
|
||||||
|
chatsQuery: '',
|
||||||
|
chatsSearchHits: null,
|
||||||
slashIndex: 0,
|
slashIndex: 0,
|
||||||
llmParked: false,
|
llmParked: false,
|
||||||
memoryRows: [],
|
memoryRows: [],
|
||||||
@@ -1769,6 +1771,23 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function chatMatchesQuery(chat, q) {
|
||||||
|
if (!q) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
const title = String(chat?.title || '').toLowerCase();
|
||||||
|
if (title.includes(q)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
const msgs = chat?.messages || [];
|
||||||
|
for (const m of msgs) {
|
||||||
|
if (String(m?.content || '').toLowerCase().includes(q)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
function renderChatsList() {
|
function renderChatsList() {
|
||||||
const root = $('sa_chats_list');
|
const root = $('sa_chats_list');
|
||||||
if (!root) {
|
if (!root) {
|
||||||
@@ -1776,19 +1795,28 @@
|
|||||||
}
|
}
|
||||||
root.innerHTML = '';
|
root.innerHTML = '';
|
||||||
syncHistoryBadge();
|
syncHistoryBadge();
|
||||||
const chats = (state.chats || [])
|
const q = (state.chatsQuery || '').trim().toLowerCase();
|
||||||
|
let chats = (state.chats || [])
|
||||||
.slice()
|
.slice()
|
||||||
.sort((a, b) => (b.updatedAt || 0) - (a.updatedAt || 0))
|
.sort((a, b) => (b.updatedAt || 0) - (a.updatedAt || 0))
|
||||||
.filter((c) => (c.messages || []).length > 0);
|
.filter((c) => (c.messages || []).length > 0 || (c.messages_count || 0) > 0);
|
||||||
|
if (q) {
|
||||||
|
const local = chats.filter((c) => chatMatchesQuery(c, q));
|
||||||
|
const seen = new Set(local.map((c) => c.id));
|
||||||
|
const extra = (state.chatsSearchHits || []).filter((h) => h && h.id && !seen.has(h.id));
|
||||||
|
chats = local.concat(extra);
|
||||||
|
}
|
||||||
if (!chats.length) {
|
if (!chats.length) {
|
||||||
root.innerHTML = '<div class="sa-chats-empty">Пока пусто. Напиши что-нибудь в чат — он появится здесь. Переключение вернёт и сообщения, и параметры Generate.</div>';
|
root.innerHTML = q
|
||||||
|
? '<div class="sa-chats-empty">Ничего не нашлось.</div>'
|
||||||
|
: '<div class="sa-chats-empty">Пока пусто. Напиши что-нибудь в чат — он появится здесь. Переключение вернёт и сообщения, и параметры Generate.</div>';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
for (const c of chats) {
|
for (const c of chats) {
|
||||||
const row = document.createElement('div');
|
const row = document.createElement('div');
|
||||||
row.className = 'sa-chat-row' + (c.id === state.activeChatId ? ' sa-chat-row-active' : '');
|
row.className = 'sa-chat-row' + (c.id === state.activeChatId ? ' sa-chat-row-active' : '');
|
||||||
row.dataset.id = c.id;
|
row.dataset.id = c.id;
|
||||||
const n = (c.messages || []).length;
|
const n = (c.messages || []).length || Number(c.messages_count) || 0;
|
||||||
const bits = [];
|
const bits = [];
|
||||||
if (c.params?.width && c.params?.height) {
|
if (c.params?.width && c.params?.height) {
|
||||||
bits.push(`${c.params.width}×${c.params.height}`);
|
bits.push(`${c.params.width}×${c.params.height}`);
|
||||||
@@ -1818,6 +1846,11 @@
|
|||||||
btn?.setAttribute('aria-expanded', state.chatsPanelOpen ? 'true' : 'false');
|
btn?.setAttribute('aria-expanded', state.chatsPanelOpen ? 'true' : 'false');
|
||||||
if (state.chatsPanelOpen) {
|
if (state.chatsPanelOpen) {
|
||||||
saveActiveChatToStore();
|
saveActiveChatToStore();
|
||||||
|
const search = $('sa_chats_search');
|
||||||
|
if (search) {
|
||||||
|
search.value = state.chatsQuery || '';
|
||||||
|
search.focus();
|
||||||
|
}
|
||||||
renderChatsList();
|
renderChatsList();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1878,7 +1911,23 @@
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
saveActiveChatToStore({ dropEmpty: true });
|
saveActiveChatToStore({ dropEmpty: true });
|
||||||
const chat = findChat(id);
|
let chat = findChat(id);
|
||||||
|
if (!chat || !(chat.messages || []).length) {
|
||||||
|
try {
|
||||||
|
const full = await diskPersist()?.getChat?.(id);
|
||||||
|
if (full) {
|
||||||
|
const idx = (state.chats || []).findIndex((c) => c.id === id);
|
||||||
|
if (idx >= 0) {
|
||||||
|
state.chats[idx] = full;
|
||||||
|
} else {
|
||||||
|
state.chats.unshift(full);
|
||||||
|
}
|
||||||
|
chat = full;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('Assistent: getChat failed', id, e);
|
||||||
|
}
|
||||||
|
}
|
||||||
if (!chat) {
|
if (!chat) {
|
||||||
setStatus('Чат не найден');
|
setStatus('Чат не найден');
|
||||||
return;
|
return;
|
||||||
@@ -3346,7 +3395,9 @@
|
|||||||
if (!state.busy) {
|
if (!state.busy) {
|
||||||
stopBusyUi(src ? 'Generate готов' : 'Generate завершён (новое изображение не найдено)');
|
stopBusyUi(src ? 'Generate готов' : 'Generate завершён (новое изображение не найдено)');
|
||||||
}
|
}
|
||||||
if (state.view === 'chat') {
|
// Auto-critique loads the model itself on the next request — don't pay for it twice.
|
||||||
|
const paneVisible = !!document.getElementById('swarm_assistent_root')?.offsetParent;
|
||||||
|
if (state.view === 'chat' && paneVisible && !$('sa_auto_critique')?.checked) {
|
||||||
warmLlm();
|
warmLlm();
|
||||||
}
|
}
|
||||||
if (src) {
|
if (src) {
|
||||||
@@ -5140,6 +5191,9 @@
|
|||||||
? `Карточка Assistent сохранена · ${data.path}`
|
? `Карточка Assistent сохранена · ${data.path}`
|
||||||
: `Черновик + wanted · ${data.path}`);
|
: `Черновик + wanted · ${data.path}`);
|
||||||
refreshInventory(() => renderCardsList());
|
refreshInventory(() => renderCardsList());
|
||||||
|
if (enqueue || !data.installed) {
|
||||||
|
refreshWantedQueue();
|
||||||
|
}
|
||||||
},
|
},
|
||||||
0,
|
0,
|
||||||
(err) => setCardStatus(String(err || 'Ошибка сохранения')),
|
(err) => setCardStatus(String(err || 'Ошибка сохранения')),
|
||||||
@@ -5164,6 +5218,7 @@
|
|||||||
},
|
},
|
||||||
(data) => {
|
(data) => {
|
||||||
setCardStatus(data.already ? 'Уже в wanted' : `Wanted → ${data.path}`);
|
setCardStatus(data.already ? 'Уже в wanted' : `Wanted → ${data.path}`);
|
||||||
|
refreshWantedQueue();
|
||||||
},
|
},
|
||||||
0,
|
0,
|
||||||
(err) => setCardStatus(String(err || 'Ошибка enqueue')),
|
(err) => setCardStatus(String(err || 'Ошибка enqueue')),
|
||||||
@@ -6275,6 +6330,7 @@
|
|||||||
splitter.classList.remove('sa-dragging');
|
splitter.classList.remove('sa-dragging');
|
||||||
document.body.style.cursor = '';
|
document.body.style.cursor = '';
|
||||||
document.body.style.userSelect = '';
|
document.body.style.userSelect = '';
|
||||||
|
saveUiStateToDisk();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -6394,6 +6450,28 @@
|
|||||||
switchToChat(id);
|
switchToChat(id);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
let chatsSearchTimer = null;
|
||||||
|
$('sa_chats_search')?.addEventListener('input', () => {
|
||||||
|
const q = ($('sa_chats_search')?.value || '').trim();
|
||||||
|
state.chatsQuery = q;
|
||||||
|
if (!q) {
|
||||||
|
state.chatsSearchHits = null;
|
||||||
|
renderChatsList();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
renderChatsList();
|
||||||
|
clearTimeout(chatsSearchTimer);
|
||||||
|
chatsSearchTimer = setTimeout(async () => {
|
||||||
|
try {
|
||||||
|
const hits = await diskPersist()?.searchChats?.(q);
|
||||||
|
if ((state.chatsQuery || '') !== q) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
state.chatsSearchHits = Array.isArray(hits) ? hits : [];
|
||||||
|
renderChatsList();
|
||||||
|
} catch (e) { /* ignore */ }
|
||||||
|
}, 220);
|
||||||
|
});
|
||||||
|
|
||||||
$('sa_tab_chat')?.addEventListener('click', () => setView('chat'));
|
$('sa_tab_chat')?.addEventListener('click', () => setView('chat'));
|
||||||
$('sa_tab_cards')?.addEventListener('click', () => setView('cards'));
|
$('sa_tab_cards')?.addEventListener('click', () => setView('cards'));
|
||||||
|
|||||||
+152
-30
@@ -13,6 +13,7 @@ namespace Mrleo1nid.SwarmAssistent;
|
|||||||
public partial class SwarmAssistentExtension
|
public partial class SwarmAssistentExtension
|
||||||
{
|
{
|
||||||
const int MaxCivitaiHopsFallback = 2;
|
const int MaxCivitaiHopsFallback = 2;
|
||||||
|
const int MaxToolHopsFallback = 4;
|
||||||
|
|
||||||
List<JObject> BuildOllamaMessages(string packName, bool includeBase, string contextJson, JArray userMessages, string extraSystem = null, string personaId = null, IEnumerable<string> skillIds = null)
|
List<JObject> BuildOllamaMessages(string packName, bool includeBase, string contextJson, JArray userMessages, string extraSystem = null, string personaId = null, IEnumerable<string> skillIds = null)
|
||||||
{
|
{
|
||||||
@@ -139,12 +140,12 @@ public partial class SwarmAssistentExtension
|
|||||||
Logs.Debug($"Assistent memory seed: {ex.Message}");
|
Logs.Debug($"Assistent memory seed: {ex.Message}");
|
||||||
}
|
}
|
||||||
|
|
||||||
string retrieveQuery = BuildRetrieveQuery(userMessages, contextJson);
|
string retrieveQuery = BuildRetrieveQuery(userMessages, contextJson, packName);
|
||||||
JArray hits = [];
|
JArray hits = [];
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
int topK = Config.LoadAssistant(pid)["memory_top_k"]?.Value<int?>() ?? 10;
|
AssistentMemory.RetrieveOptions opt = MemoryRetrieveOptions(pid);
|
||||||
hits = await Memory.RetrieveAsync(root, retrieveQuery, topK, embed, Config.PersonaExtendsChain(pid));
|
hits = await Memory.RetrieveAsync(root, retrieveQuery, opt.TopK, embed, Config.PersonaExtendsChain(pid), opt);
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
@@ -156,7 +157,9 @@ public partial class SwarmAssistentExtension
|
|||||||
JArray civitaiResults = [];
|
JArray civitaiResults = [];
|
||||||
string reply = "";
|
string reply = "";
|
||||||
JObject lastRaw = null;
|
JObject lastRaw = null;
|
||||||
int maxHops = CfgInt("max_civitai_hops", MaxCivitaiHopsFallback);
|
int maxHops = Math.Max(CfgInt("max_civitai_hops", MaxCivitaiHopsFallback), CfgInt("max_tool_hops", MaxToolHopsFallback));
|
||||||
|
HashSet<string> hopDone = new(StringComparer.OrdinalIgnoreCase);
|
||||||
|
var chain = Config.PersonaExtendsChain(pid);
|
||||||
for (int hop = 0; hop < maxHops; hop++)
|
for (int hop = 0; hop < maxHops; hop++)
|
||||||
{
|
{
|
||||||
if (onHopStart is not null)
|
if (onHopStart is not null)
|
||||||
@@ -166,44 +169,37 @@ public partial class SwarmAssistentExtension
|
|||||||
(reply, lastRaw) = await CallOllamaChat(root, modelName, messages, stream: onDelta is not null, onDelta, pid);
|
(reply, lastRaw) = await CallOllamaChat(root, modelName, messages, stream: onDelta is not null, onDelta, pid);
|
||||||
JObject patch = TryParsePatch(reply);
|
JObject patch = TryParsePatch(reply);
|
||||||
await ApplyMemoryActions(root, patch, embed, pid);
|
await ApplyMemoryActions(root, patch, embed, pid);
|
||||||
if (hop + 1 >= maxHops || !WantsCivitaiSearch(patch))
|
if (hop + 1 >= maxHops)
|
||||||
{
|
{
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
string query = ExtractSearchQuery(patch);
|
string tool = NextToolHop(patch);
|
||||||
if (string.IsNullOrWhiteSpace(query))
|
if (string.IsNullOrWhiteSpace(tool))
|
||||||
{
|
{
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
JObject search = await AssistentSearchCivitai(session, query, 8);
|
(string follow, JArray civitaiHop) = await RunToolHop(session, root, embed, pid, chain, patch, tool, hopDone);
|
||||||
if (search["error"] is not null)
|
if (follow is null)
|
||||||
{
|
{
|
||||||
messages.Add(new JObject { ["role"] = "assistant", ["content"] = reply });
|
break;
|
||||||
messages.Add(new JObject
|
}
|
||||||
{
|
if (civitaiHop is { Count: > 0 })
|
||||||
["role"] = "user",
|
{
|
||||||
["content"] = $"Civitai search failed: {search["error"]}. Continue without download — use only available_loras from context.",
|
civitaiResults = civitaiHop;
|
||||||
});
|
|
||||||
continue;
|
|
||||||
}
|
}
|
||||||
civitaiResults = search["results"] as JArray ?? [];
|
|
||||||
messages.Add(new JObject { ["role"] = "assistant", ["content"] = reply });
|
messages.Add(new JObject { ["role"] = "assistant", ["content"] = reply });
|
||||||
messages.Add(new JObject
|
messages.Add(new JObject { ["role"] = "user", ["content"] = follow });
|
||||||
{
|
|
||||||
["role"] = "user",
|
|
||||||
["content"] =
|
|
||||||
"Civitai search results (JSON). Prefer `krea_likely: true`. Do NOT download yourself — the UI shows Confirm cards. " +
|
|
||||||
"Pick useful LoRAs from results or available_loras, emit a normal patch (prompt/loras). " +
|
|
||||||
"Omit search_civitai from actions unless you need a different query.\n```json\n" +
|
|
||||||
civitaiResults.ToString(Newtonsoft.Json.Formatting.None) + "\n```",
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
return (reply, lastRaw, civitaiResults);
|
return (reply, lastRaw, civitaiResults);
|
||||||
}
|
}
|
||||||
|
|
||||||
static string BuildRetrieveQuery(JArray userMessages, string contextJson)
|
static string BuildRetrieveQuery(JArray userMessages, string contextJson, string packName = null)
|
||||||
{
|
{
|
||||||
StringBuilder sb = new();
|
StringBuilder sb = new();
|
||||||
|
if (!string.IsNullOrWhiteSpace(packName))
|
||||||
|
{
|
||||||
|
sb.Append(packName).Append(' ');
|
||||||
|
}
|
||||||
if (!string.IsNullOrWhiteSpace(contextJson))
|
if (!string.IsNullOrWhiteSpace(contextJson))
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
@@ -216,7 +212,7 @@ public partial class SwarmAssistentExtension
|
|||||||
}
|
}
|
||||||
if (ctx["enabled_loras"] is JArray en)
|
if (ctx["enabled_loras"] is JArray en)
|
||||||
{
|
{
|
||||||
foreach (JToken t in en.Take(8))
|
foreach (JToken t in en.Take(12))
|
||||||
{
|
{
|
||||||
string n = t?["name"]?.ToString() ?? t?.ToString();
|
string n = t?["name"]?.ToString() ?? t?.ToString();
|
||||||
if (!string.IsNullOrWhiteSpace(n))
|
if (!string.IsNullOrWhiteSpace(n))
|
||||||
@@ -229,23 +225,149 @@ public partial class SwarmAssistentExtension
|
|||||||
{
|
{
|
||||||
sb.Append("krea ").Append(ctx["krea_profile"]).Append(' ');
|
sb.Append("krea ").Append(ctx["krea_profile"]).Append(' ');
|
||||||
}
|
}
|
||||||
|
string aspect = ctx["aspect"]?.ToString();
|
||||||
|
if (!string.IsNullOrWhiteSpace(aspect))
|
||||||
|
{
|
||||||
|
sb.Append(aspect).Append(' ');
|
||||||
|
}
|
||||||
|
string prompt = ctx["prompt"]?.ToString();
|
||||||
|
if (!string.IsNullOrWhiteSpace(prompt))
|
||||||
|
{
|
||||||
|
sb.Append(prompt.Length > 400 ? prompt[..400] : prompt).Append(' ');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
catch
|
catch
|
||||||
{
|
{
|
||||||
// ignore
|
// ignore
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
foreach (JToken msg in (userMessages ?? []).Reverse().Take(2))
|
foreach (JToken msg in (userMessages ?? []).Reverse().Take(3))
|
||||||
{
|
{
|
||||||
if (msg is JObject mo && string.Equals(mo["role"]?.ToString(), "user", StringComparison.OrdinalIgnoreCase))
|
if (msg is JObject mo && string.Equals(mo["role"]?.ToString(), "user", StringComparison.OrdinalIgnoreCase))
|
||||||
{
|
{
|
||||||
sb.Append(mo["content"]?.ToString()).Append(' ');
|
string c = mo["content"]?.ToString() ?? "";
|
||||||
|
sb.Append(c.Length > 500 ? c[..500] : c).Append(' ');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
string q = CollapseWs(sb.ToString());
|
string q = CollapseWs(sb.ToString());
|
||||||
return string.IsNullOrWhiteSpace(q) ? "krea2 prompting" : q;
|
return string.IsNullOrWhiteSpace(q) ? "krea2 prompting" : q;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
AssistentMemory.RetrieveOptions MemoryRetrieveOptions(string pid)
|
||||||
|
{
|
||||||
|
JObject a = Config.LoadAssistant(pid) ?? new JObject();
|
||||||
|
AssistentMemory.RetrieveOptions opt = new()
|
||||||
|
{
|
||||||
|
TopK = a["memory_top_k"]?.Value<int?>() ?? 10,
|
||||||
|
MinScore = a["memory_min_score"]?.Value<float?>() ?? 0.32f,
|
||||||
|
ApplyQuotas = true,
|
||||||
|
};
|
||||||
|
if (a["memory_quotas"] is JObject quotas)
|
||||||
|
{
|
||||||
|
Dictionary<string, int> d = new(StringComparer.OrdinalIgnoreCase);
|
||||||
|
foreach (JProperty p in quotas.Properties())
|
||||||
|
{
|
||||||
|
d[p.Name] = p.Value?.Value<int?>() ?? 2;
|
||||||
|
}
|
||||||
|
opt.Quotas = d;
|
||||||
|
}
|
||||||
|
return opt;
|
||||||
|
}
|
||||||
|
|
||||||
|
async Task<(string follow, JArray civitai)> RunToolHop(
|
||||||
|
Session session,
|
||||||
|
string root,
|
||||||
|
string embed,
|
||||||
|
string pid,
|
||||||
|
IEnumerable<string> chain,
|
||||||
|
JObject patch,
|
||||||
|
string tool,
|
||||||
|
HashSet<string> hopDone)
|
||||||
|
{
|
||||||
|
if (tool == "memory_get")
|
||||||
|
{
|
||||||
|
JArray got = [];
|
||||||
|
foreach (JToken t in patch["memories"] as JArray ?? [])
|
||||||
|
{
|
||||||
|
if (t is not JObject mo)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
string kind = mo["kind"]?.ToString() ?? "note";
|
||||||
|
string key = mo["key"]?.ToString() ?? "";
|
||||||
|
if (string.IsNullOrWhiteSpace(key))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
string sig = $"get:{kind}:{key}";
|
||||||
|
if (!hopDone.Add(sig))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
JObject row = Memory.Get(kind, key, chain);
|
||||||
|
got.Add(row ?? new JObject { ["kind"] = kind, ["key"] = key, ["missing"] = true });
|
||||||
|
}
|
||||||
|
if (got.Count == 0)
|
||||||
|
{
|
||||||
|
return (null, null);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
"memory_get results (JSON). Use these facts; omit memory_get unless you need a different key.\n```json\n"
|
||||||
|
+ got.ToString(Newtonsoft.Json.Formatting.None) + "\n```",
|
||||||
|
null);
|
||||||
|
}
|
||||||
|
if (tool == "memory_search")
|
||||||
|
{
|
||||||
|
string q = ExtractMemoryQuery(patch);
|
||||||
|
if (string.IsNullOrWhiteSpace(q) || !hopDone.Add("search:" + q))
|
||||||
|
{
|
||||||
|
return (null, null);
|
||||||
|
}
|
||||||
|
string kind = patch["memory_kind"]?.ToString();
|
||||||
|
int topK = Config.LoadAssistant(pid)["memory_top_k"]?.Value<int?>() ?? 10;
|
||||||
|
JArray rows = await Memory.SearchAsync(root, q, kind, topK, embed, chain);
|
||||||
|
return (
|
||||||
|
"memory_search results (JSON, hybrid FTS+vector). Omit memory_search unless you need a different query.\n```json\n"
|
||||||
|
+ rows.ToString(Newtonsoft.Json.Formatting.None) + "\n```",
|
||||||
|
null);
|
||||||
|
}
|
||||||
|
if (tool == "lookup_tags")
|
||||||
|
{
|
||||||
|
string q = ExtractTagQuery(patch);
|
||||||
|
if (string.IsNullOrWhiteSpace(q) || !hopDone.Add("tags:" + q))
|
||||||
|
{
|
||||||
|
return (null, null);
|
||||||
|
}
|
||||||
|
int lim = Config.LoadAssistant(pid)["tag_lookup_limit"]?.Value<int?>() ?? 20;
|
||||||
|
JArray tags = Memory.LookupTags(q, lim);
|
||||||
|
return (
|
||||||
|
"lookup_tags results from Danbooru csv (canonical name, aliases, post_count). Krea prompts stay natural prose — use this to check spelling/aliases, do not dump tag soup.\n```json\n"
|
||||||
|
+ tags.ToString(Newtonsoft.Json.Formatting.None) + "\n```",
|
||||||
|
null);
|
||||||
|
}
|
||||||
|
if (tool == "civitai")
|
||||||
|
{
|
||||||
|
string query = ExtractSearchQuery(patch);
|
||||||
|
if (string.IsNullOrWhiteSpace(query) || !hopDone.Add("civitai:" + query))
|
||||||
|
{
|
||||||
|
return (null, null);
|
||||||
|
}
|
||||||
|
JObject search = await AssistentSearchCivitai(session, query, 8);
|
||||||
|
if (search["error"] is not null)
|
||||||
|
{
|
||||||
|
return ($"Civitai search failed: {search["error"]}. Continue without download — use only available_loras from context.", null);
|
||||||
|
}
|
||||||
|
JArray civitaiResults = search["results"] as JArray ?? [];
|
||||||
|
return (
|
||||||
|
"Civitai search results (JSON). Prefer `krea_likely: true`. Do NOT download yourself — the UI shows Confirm cards. " +
|
||||||
|
"Pick useful LoRAs from results or available_loras, emit a normal patch (prompt/loras). " +
|
||||||
|
"Omit search_civitai from actions unless you need a different query.\n```json\n" +
|
||||||
|
civitaiResults.ToString(Newtonsoft.Json.Formatting.None) + "\n```",
|
||||||
|
civitaiResults);
|
||||||
|
}
|
||||||
|
return (null, null);
|
||||||
|
}
|
||||||
|
|
||||||
static string InjectMemoryHits(string contextJson, JArray hits, JObject exact = null)
|
static string InjectMemoryHits(string contextJson, JArray hits, JObject exact = null)
|
||||||
{
|
{
|
||||||
JObject ctx;
|
JObject ctx;
|
||||||
|
|||||||
@@ -0,0 +1,373 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Globalization;
|
||||||
|
using System.IO;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Text;
|
||||||
|
using Microsoft.Data.Sqlite;
|
||||||
|
using Newtonsoft.Json.Linq;
|
||||||
|
using SwarmUI.Utils;
|
||||||
|
|
||||||
|
namespace Mrleo1nid.SwarmAssistent;
|
||||||
|
|
||||||
|
public sealed partial class AssistentMemory
|
||||||
|
{
|
||||||
|
static readonly Dictionary<int, string> TagCategories = new()
|
||||||
|
{
|
||||||
|
[0] = "general",
|
||||||
|
[1] = "artist",
|
||||||
|
[3] = "copyright",
|
||||||
|
[4] = "character",
|
||||||
|
[5] = "meta",
|
||||||
|
};
|
||||||
|
|
||||||
|
void TryIndexTags()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
EnsureTagsIndex();
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Logs.Debug($"AssistentMemory tags index: {ex.Message}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void EnsureTagsSchema()
|
||||||
|
{
|
||||||
|
Exec(
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS tags (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
name TEXT NOT NULL COLLATE NOCASE UNIQUE,
|
||||||
|
category INTEGER NOT NULL DEFAULT 0,
|
||||||
|
post_count INTEGER NOT NULL DEFAULT 0,
|
||||||
|
aliases TEXT NOT NULL DEFAULT ''
|
||||||
|
);
|
||||||
|
""");
|
||||||
|
Exec("CREATE INDEX IF NOT EXISTS idx_tags_count ON tags(post_count DESC);");
|
||||||
|
Exec(
|
||||||
|
"""
|
||||||
|
CREATE VIRTUAL TABLE IF NOT EXISTS tags_fts USING fts5(
|
||||||
|
name,
|
||||||
|
aliases,
|
||||||
|
tokenize = 'unicode61 remove_diacritics 2'
|
||||||
|
);
|
||||||
|
""");
|
||||||
|
Exec(
|
||||||
|
"""
|
||||||
|
CREATE TRIGGER IF NOT EXISTS tags_fts_ai AFTER INSERT ON tags BEGIN
|
||||||
|
INSERT INTO tags_fts(rowid, name, aliases) VALUES (new.id, new.name, new.aliases);
|
||||||
|
END;
|
||||||
|
""");
|
||||||
|
Exec(
|
||||||
|
"""
|
||||||
|
CREATE TRIGGER IF NOT EXISTS tags_fts_ad AFTER DELETE ON tags BEGIN
|
||||||
|
INSERT INTO tags_fts(tags_fts, rowid) VALUES('delete', old.id);
|
||||||
|
END;
|
||||||
|
""");
|
||||||
|
Exec(
|
||||||
|
"""
|
||||||
|
CREATE TRIGGER IF NOT EXISTS tags_fts_au AFTER UPDATE ON tags BEGIN
|
||||||
|
INSERT INTO tags_fts(tags_fts, rowid) VALUES('delete', old.id);
|
||||||
|
INSERT INTO tags_fts(rowid, name, aliases) VALUES (new.id, new.name, new.aliases);
|
||||||
|
END;
|
||||||
|
""");
|
||||||
|
}
|
||||||
|
|
||||||
|
public string FindAutocompleteCsv()
|
||||||
|
{
|
||||||
|
string dir = Path.Combine(_dataRoot, "Data", "Autocompletions");
|
||||||
|
if (!Directory.Exists(dir))
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
string preferred = Path.Combine(dir, "danbooru.csv");
|
||||||
|
if (File.Exists(preferred))
|
||||||
|
{
|
||||||
|
return preferred;
|
||||||
|
}
|
||||||
|
string[] csvs = Directory.GetFiles(dir, "*.csv");
|
||||||
|
if (csvs.Length == 0)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return csvs.OrderByDescending(f => new FileInfo(f).Length).First();
|
||||||
|
}
|
||||||
|
|
||||||
|
static string CsvFingerprint(string csvPath)
|
||||||
|
{
|
||||||
|
var info = new FileInfo(csvPath);
|
||||||
|
string sha = "";
|
||||||
|
string meta = csvPath + ".gpu-rent-meta.json";
|
||||||
|
if (File.Exists(meta))
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
JObject o = JObject.Parse(File.ReadAllText(meta));
|
||||||
|
sha = o["github_blob_sha"]?.ToString() ?? "";
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return $"{info.Length}:{info.LastWriteTimeUtc.Ticks}:{sha}";
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Load SwarmUI Autocompletions csv into FTS (no embeddings). No-op if fingerprint matches.</summary>
|
||||||
|
public int EnsureTagsIndex()
|
||||||
|
{
|
||||||
|
string csv = FindAutocompleteCsv();
|
||||||
|
if (string.IsNullOrWhiteSpace(csv) || !File.Exists(csv))
|
||||||
|
{
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
string fp = CsvFingerprint(csv);
|
||||||
|
lock (_lock)
|
||||||
|
{
|
||||||
|
EnsureOpen();
|
||||||
|
if (string.Equals(GetMeta("tags_csv_fp"), fp, StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
using SqliteCommand c = _conn.CreateCommand();
|
||||||
|
c.CommandText = "SELECT COUNT(*) FROM tags";
|
||||||
|
return Convert.ToInt32(c.ExecuteScalar());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
List<(string name, int cat, int count, string aliases)> rows = [];
|
||||||
|
foreach (string line in File.ReadLines(csv, Encoding.UTF8))
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(line) || line.StartsWith('#'))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
List<string> cols = ParseCsvLine(line);
|
||||||
|
if (cols.Count < 1)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
string name = cols[0].Trim();
|
||||||
|
if (string.IsNullOrWhiteSpace(name))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
int cat = 0, count = 0;
|
||||||
|
if (cols.Count > 1)
|
||||||
|
{
|
||||||
|
_ = int.TryParse(cols[1].Trim(), NumberStyles.Integer, CultureInfo.InvariantCulture, out cat);
|
||||||
|
}
|
||||||
|
if (cols.Count > 2)
|
||||||
|
{
|
||||||
|
_ = int.TryParse(cols[2].Trim(), NumberStyles.Integer, CultureInfo.InvariantCulture, out count);
|
||||||
|
}
|
||||||
|
string aliases = cols.Count > 3 ? cols[3].Trim() : "";
|
||||||
|
rows.Add((name, cat, count, aliases));
|
||||||
|
}
|
||||||
|
|
||||||
|
lock (_lock)
|
||||||
|
{
|
||||||
|
EnsureOpen();
|
||||||
|
using SqliteTransaction tx = _conn.BeginTransaction();
|
||||||
|
using (SqliteCommand del = _conn.CreateCommand())
|
||||||
|
{
|
||||||
|
del.Transaction = tx;
|
||||||
|
del.CommandText = "DELETE FROM tags";
|
||||||
|
del.ExecuteNonQuery();
|
||||||
|
}
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using (SqliteCommand delFts = _conn.CreateCommand())
|
||||||
|
{
|
||||||
|
delFts.Transaction = tx;
|
||||||
|
delFts.CommandText = "DELETE FROM tags_fts";
|
||||||
|
delFts.ExecuteNonQuery();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// FTS table missing
|
||||||
|
}
|
||||||
|
using (SqliteCommand ins = _conn.CreateCommand())
|
||||||
|
{
|
||||||
|
ins.Transaction = tx;
|
||||||
|
ins.CommandText = "INSERT OR REPLACE INTO tags(name, category, post_count, aliases) VALUES($n,$c,$p,$a)";
|
||||||
|
var pn = ins.Parameters.Add("$n", SqliteType.Text);
|
||||||
|
var pc = ins.Parameters.Add("$c", SqliteType.Integer);
|
||||||
|
var pp = ins.Parameters.Add("$p", SqliteType.Integer);
|
||||||
|
var pa = ins.Parameters.Add("$a", SqliteType.Text);
|
||||||
|
foreach (var row in rows)
|
||||||
|
{
|
||||||
|
pn.Value = row.name;
|
||||||
|
pc.Value = row.cat;
|
||||||
|
pp.Value = row.count;
|
||||||
|
pa.Value = row.aliases ?? "";
|
||||||
|
ins.ExecuteNonQuery();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
tx.Commit();
|
||||||
|
SetMeta("tags_csv_fp", fp);
|
||||||
|
SetMeta("tags_count", rows.Count.ToString(CultureInfo.InvariantCulture));
|
||||||
|
Logs.Debug($"AssistentMemory indexed {rows.Count} tags from {Path.GetFileName(csv)}");
|
||||||
|
return rows.Count;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public int TagCount()
|
||||||
|
{
|
||||||
|
lock (_lock)
|
||||||
|
{
|
||||||
|
EnsureOpen();
|
||||||
|
if (!TableExists("tags"))
|
||||||
|
{
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
using SqliteCommand cmd = _conn.CreateCommand();
|
||||||
|
cmd.CommandText = "SELECT COUNT(*) FROM tags";
|
||||||
|
return Convert.ToInt32(cmd.ExecuteScalar());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Prefix + FTS lookup over the Danbooru csv. No embeddings.</summary>
|
||||||
|
public JArray LookupTags(string query, int limit = 20)
|
||||||
|
{
|
||||||
|
query = (query ?? "").Trim();
|
||||||
|
if (query.Length < 1)
|
||||||
|
{
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
TryIndexTags();
|
||||||
|
int cap = Math.Clamp(limit, 1, 40);
|
||||||
|
string underscored = query.Replace(' ', '_');
|
||||||
|
Dictionary<string, JObject> byName = new(StringComparer.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
void Add(SqliteDataReader reader)
|
||||||
|
{
|
||||||
|
string name = reader.GetString(0);
|
||||||
|
if (byName.ContainsKey(name))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
int cat = reader.GetInt32(1);
|
||||||
|
TagCategories.TryGetValue(cat, out string catName);
|
||||||
|
byName[name] = new JObject
|
||||||
|
{
|
||||||
|
["name"] = name,
|
||||||
|
["category"] = cat,
|
||||||
|
["category_name"] = catName ?? "general",
|
||||||
|
["post_count"] = reader.GetInt32(2),
|
||||||
|
["aliases"] = reader.IsDBNull(3) ? "" : reader.GetString(3),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
lock (_lock)
|
||||||
|
{
|
||||||
|
EnsureOpen();
|
||||||
|
if (!TableExists("tags"))
|
||||||
|
{
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using SqliteCommand cmd = _conn.CreateCommand();
|
||||||
|
cmd.CommandText =
|
||||||
|
"""
|
||||||
|
SELECT name, category, post_count, aliases FROM tags
|
||||||
|
WHERE name LIKE $p ESCAPE '\' OR (aliases != '' AND aliases LIKE $a)
|
||||||
|
ORDER BY post_count DESC LIMIT $lim
|
||||||
|
""";
|
||||||
|
cmd.Parameters.AddWithValue("$p", EscapeLike(underscored) + "%");
|
||||||
|
cmd.Parameters.AddWithValue("$a", "%" + EscapeLike(query) + "%");
|
||||||
|
cmd.Parameters.AddWithValue("$lim", cap);
|
||||||
|
using SqliteDataReader reader = cmd.ExecuteReader();
|
||||||
|
while (reader.Read())
|
||||||
|
{
|
||||||
|
Add(reader);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Logs.Debug($"AssistentMemory tag prefix: {ex.Message}");
|
||||||
|
}
|
||||||
|
|
||||||
|
string match = BuildFtsMatch(query);
|
||||||
|
if (!string.IsNullOrWhiteSpace(match) && TableExists("tags_fts") && byName.Count < cap)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using SqliteCommand cmd = _conn.CreateCommand();
|
||||||
|
cmd.CommandText =
|
||||||
|
"""
|
||||||
|
SELECT t.name, t.category, t.post_count, t.aliases
|
||||||
|
FROM tags t
|
||||||
|
WHERE t.id IN (SELECT rowid FROM tags_fts WHERE tags_fts MATCH $q)
|
||||||
|
ORDER BY t.post_count DESC LIMIT $lim
|
||||||
|
""";
|
||||||
|
cmd.Parameters.AddWithValue("$q", match);
|
||||||
|
cmd.Parameters.AddWithValue("$lim", cap);
|
||||||
|
using SqliteDataReader reader = cmd.ExecuteReader();
|
||||||
|
while (reader.Read())
|
||||||
|
{
|
||||||
|
Add(reader);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Logs.Debug($"AssistentMemory tag FTS: {ex.Message}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return new JArray(byName.Values.OrderByDescending(t => t["post_count"]?.Value<int>() ?? 0).Take(cap));
|
||||||
|
}
|
||||||
|
|
||||||
|
static string EscapeLike(string s)
|
||||||
|
{
|
||||||
|
return (s ?? "").Replace(@"\", @"\\").Replace("%", @"\%").Replace("_", @"\_");
|
||||||
|
}
|
||||||
|
|
||||||
|
static List<string> ParseCsvLine(string line)
|
||||||
|
{
|
||||||
|
List<string> cols = [];
|
||||||
|
StringBuilder cur = new();
|
||||||
|
bool quoted = false;
|
||||||
|
for (int i = 0; i < line.Length; i++)
|
||||||
|
{
|
||||||
|
char c = line[i];
|
||||||
|
if (quoted)
|
||||||
|
{
|
||||||
|
if (c == '"')
|
||||||
|
{
|
||||||
|
if (i + 1 < line.Length && line[i + 1] == '"')
|
||||||
|
{
|
||||||
|
cur.Append('"');
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
quoted = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
cur.Append(c);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else if (c == '"')
|
||||||
|
{
|
||||||
|
quoted = true;
|
||||||
|
}
|
||||||
|
else if (c == ',')
|
||||||
|
{
|
||||||
|
cols.Add(cur.ToString());
|
||||||
|
cur.Clear();
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
cur.Append(c);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
cols.Add(cur.ToString());
|
||||||
|
return cols;
|
||||||
|
}
|
||||||
|
}
|
||||||
+393
-40
@@ -4,6 +4,7 @@ using System.IO;
|
|||||||
using System.Linq;
|
using System.Linq;
|
||||||
using System.Net.Http;
|
using System.Net.Http;
|
||||||
using System.Text;
|
using System.Text;
|
||||||
|
using System.Text.RegularExpressions;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using Microsoft.Data.Sqlite;
|
using Microsoft.Data.Sqlite;
|
||||||
using Newtonsoft.Json.Linq;
|
using Newtonsoft.Json.Linq;
|
||||||
@@ -14,10 +15,31 @@ namespace Mrleo1nid.SwarmAssistent;
|
|||||||
/// <summary>Local SQLite vector memory with Ollama /api/embed.
|
/// <summary>Local SQLite vector memory with Ollama /api/embed.
|
||||||
/// Two layers: shared (persona='') is visible to every personality; personal (persona=id)
|
/// Two layers: shared (persona='') is visible to every personality; personal (persona=id)
|
||||||
/// is not written back to shared. On retrieve, personal overwrites shared on the same kind+key.</summary>
|
/// is not written back to shared. On retrieve, personal overwrites shared on the same kind+key.</summary>
|
||||||
public sealed class AssistentMemory : IDisposable
|
public sealed partial class AssistentMemory : IDisposable
|
||||||
{
|
{
|
||||||
public const string SharedPersona = "";
|
public const string SharedPersona = "";
|
||||||
|
|
||||||
|
public sealed class RetrieveOptions
|
||||||
|
{
|
||||||
|
public int TopK { get; set; } = 10;
|
||||||
|
public float MinScore { get; set; } = 0.32f;
|
||||||
|
public string KindFilter { get; set; }
|
||||||
|
public IReadOnlyDictionary<string, int> Quotas { get; set; }
|
||||||
|
public bool ApplyQuotas { get; set; } = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
static readonly Dictionary<string, int> DefaultQuotas = new(StringComparer.OrdinalIgnoreCase)
|
||||||
|
{
|
||||||
|
["card"] = 3,
|
||||||
|
["lora"] = 3,
|
||||||
|
["pitfall"] = 3,
|
||||||
|
["path"] = 2,
|
||||||
|
["note"] = 4,
|
||||||
|
["model"] = 2,
|
||||||
|
["aspect"] = 1,
|
||||||
|
};
|
||||||
|
|
||||||
|
readonly string _dataRoot;
|
||||||
readonly string _dbPath;
|
readonly string _dbPath;
|
||||||
readonly HttpClient _http;
|
readonly HttpClient _http;
|
||||||
readonly object _lock = new();
|
readonly object _lock = new();
|
||||||
@@ -28,7 +50,8 @@ public sealed class AssistentMemory : IDisposable
|
|||||||
|
|
||||||
public AssistentMemory(string dataRoot, HttpClient http, string defaultEmbedModel = "nomic-embed-text")
|
public AssistentMemory(string dataRoot, HttpClient http, string defaultEmbedModel = "nomic-embed-text")
|
||||||
{
|
{
|
||||||
string dir = Path.Combine(dataRoot ?? ".", "Assistent", "memory");
|
_dataRoot = string.IsNullOrWhiteSpace(dataRoot) ? "." : dataRoot;
|
||||||
|
string dir = Path.Combine(_dataRoot, "Assistent", "memory");
|
||||||
Directory.CreateDirectory(dir);
|
Directory.CreateDirectory(dir);
|
||||||
_dbPath = Path.Combine(dir, "assistent.sqlite");
|
_dbPath = Path.Combine(dir, "assistent.sqlite");
|
||||||
_http = http;
|
_http = http;
|
||||||
@@ -63,6 +86,8 @@ public sealed class AssistentMemory : IDisposable
|
|||||||
}
|
}
|
||||||
_conn = new SqliteConnection($"Data Source={_dbPath}");
|
_conn = new SqliteConnection($"Data Source={_dbPath}");
|
||||||
_conn.Open();
|
_conn.Open();
|
||||||
|
TryPragma("journal_mode=WAL");
|
||||||
|
TryPragma("busy_timeout=5000");
|
||||||
using (SqliteCommand cmd = _conn.CreateCommand())
|
using (SqliteCommand cmd = _conn.CreateCommand())
|
||||||
{
|
{
|
||||||
cmd.CommandText =
|
cmd.CommandText =
|
||||||
@@ -96,6 +121,23 @@ public sealed class AssistentMemory : IDisposable
|
|||||||
""";
|
""";
|
||||||
idx.ExecuteNonQuery();
|
idx.ExecuteNonQuery();
|
||||||
}
|
}
|
||||||
|
EnsureFts();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
EnsureTagsSchema();
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Logs.Debug($"AssistentMemory tags schema: {ex.Message}");
|
||||||
|
}
|
||||||
|
try
|
||||||
|
{
|
||||||
|
EnsureStoreSchema();
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Logs.Debug($"AssistentMemory store schema: {ex.Message}");
|
||||||
|
}
|
||||||
_embedModel = GetMeta("embed_model") ?? _embedModel;
|
_embedModel = GetMeta("embed_model") ?? _embedModel;
|
||||||
_ = int.TryParse(GetMeta("dims"), out _dims);
|
_ = int.TryParse(GetMeta("dims"), out _dims);
|
||||||
_ = int.TryParse(GetMeta("seed_version"), out _seedVersion);
|
_ = int.TryParse(GetMeta("seed_version"), out _seedVersion);
|
||||||
@@ -154,6 +196,89 @@ public sealed class AssistentMemory : IDisposable
|
|||||||
Logs.Debug("AssistentMemory: migrated sqlite to shared+personal persona column (existing rows → shared)");
|
Logs.Debug("AssistentMemory: migrated sqlite to shared+personal persona column (existing rows → shared)");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void TryPragma(string pragma)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using SqliteCommand cmd = _conn.CreateCommand();
|
||||||
|
cmd.CommandText = "PRAGMA " + pragma;
|
||||||
|
cmd.ExecuteNonQuery();
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Logs.Debug($"AssistentMemory PRAGMA {pragma}: {ex.Message}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
bool TableExists(string name)
|
||||||
|
{
|
||||||
|
using SqliteCommand cmd = _conn.CreateCommand();
|
||||||
|
cmd.CommandText = "SELECT 1 FROM sqlite_master WHERE type IN ('table','view') AND name = $n LIMIT 1";
|
||||||
|
cmd.Parameters.AddWithValue("$n", name);
|
||||||
|
return cmd.ExecuteScalar() is not null;
|
||||||
|
}
|
||||||
|
|
||||||
|
void Exec(string sql)
|
||||||
|
{
|
||||||
|
using SqliteCommand cmd = _conn.CreateCommand();
|
||||||
|
cmd.CommandText = sql;
|
||||||
|
cmd.ExecuteNonQuery();
|
||||||
|
}
|
||||||
|
|
||||||
|
void EnsureFts()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
Exec(
|
||||||
|
"""
|
||||||
|
CREATE VIRTUAL TABLE IF NOT EXISTS memories_fts USING fts5(
|
||||||
|
key,
|
||||||
|
text,
|
||||||
|
tokenize = 'unicode61 remove_diacritics 2'
|
||||||
|
);
|
||||||
|
""");
|
||||||
|
Exec(
|
||||||
|
"""
|
||||||
|
CREATE TRIGGER IF NOT EXISTS memories_fts_ai AFTER INSERT ON memories BEGIN
|
||||||
|
INSERT INTO memories_fts(rowid, key, text) VALUES (new.id, new.key, new.text);
|
||||||
|
END;
|
||||||
|
""");
|
||||||
|
Exec(
|
||||||
|
"""
|
||||||
|
CREATE TRIGGER IF NOT EXISTS memories_fts_ad AFTER DELETE ON memories BEGIN
|
||||||
|
INSERT INTO memories_fts(memories_fts, rowid) VALUES('delete', old.id);
|
||||||
|
END;
|
||||||
|
""");
|
||||||
|
Exec(
|
||||||
|
"""
|
||||||
|
CREATE TRIGGER IF NOT EXISTS memories_fts_au AFTER UPDATE ON memories BEGIN
|
||||||
|
INSERT INTO memories_fts(memories_fts, rowid) VALUES('delete', old.id);
|
||||||
|
INSERT INTO memories_fts(rowid, key, text) VALUES (new.id, new.key, new.text);
|
||||||
|
END;
|
||||||
|
""");
|
||||||
|
int mem = 0, fts = 0;
|
||||||
|
using (SqliteCommand c = _conn.CreateCommand())
|
||||||
|
{
|
||||||
|
c.CommandText = "SELECT COUNT(*) FROM memories";
|
||||||
|
mem = Convert.ToInt32(c.ExecuteScalar());
|
||||||
|
}
|
||||||
|
using (SqliteCommand c = _conn.CreateCommand())
|
||||||
|
{
|
||||||
|
c.CommandText = "SELECT COUNT(*) FROM memories_fts";
|
||||||
|
fts = Convert.ToInt32(c.ExecuteScalar());
|
||||||
|
}
|
||||||
|
if (mem != fts)
|
||||||
|
{
|
||||||
|
Exec("DELETE FROM memories_fts");
|
||||||
|
Exec("INSERT INTO memories_fts(rowid, key, text) SELECT id, key, text FROM memories");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Logs.Debug($"AssistentMemory FTS5 unavailable, cosine-only: {ex.Message}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
string GetMeta(string key)
|
string GetMeta(string key)
|
||||||
{
|
{
|
||||||
using SqliteCommand cmd = _conn.CreateCommand();
|
using SqliteCommand cmd = _conn.CreateCommand();
|
||||||
@@ -268,6 +393,7 @@ public sealed class AssistentMemory : IDisposable
|
|||||||
List<JObject> docs = config.LoadMemorySeedDocs();
|
List<JObject> docs = config.LoadMemorySeedDocs();
|
||||||
if (docs.Count == 0)
|
if (docs.Count == 0)
|
||||||
{
|
{
|
||||||
|
TryIndexTags();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -287,6 +413,7 @@ public sealed class AssistentMemory : IDisposable
|
|||||||
}
|
}
|
||||||
if (!missing)
|
if (!missing)
|
||||||
{
|
{
|
||||||
|
TryIndexTags();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -299,6 +426,7 @@ public sealed class AssistentMemory : IDisposable
|
|||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
Logs.Debug($"AssistentMemory seed defer (embed unavailable): {ex.Message}");
|
Logs.Debug($"AssistentMemory seed defer (embed unavailable): {ex.Message}");
|
||||||
|
TryIndexTags();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -356,6 +484,7 @@ public sealed class AssistentMemory : IDisposable
|
|||||||
Logs.Debug($"AssistentMemory seed item {kind}/{key}: {ex.Message}");
|
Logs.Debug($"AssistentMemory seed item {kind}/{key}: {ex.Message}");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
TryIndexTags();
|
||||||
}
|
}
|
||||||
|
|
||||||
public void Upsert(string kind, string key, string text, string source, JToken meta, float[] embedding, string persona = null)
|
public void Upsert(string kind, string key, string text, string source, JToken meta, float[] embedding, string persona = null)
|
||||||
@@ -428,29 +557,8 @@ public sealed class AssistentMemory : IDisposable
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Retrieve shared + the given persona chain. Personal overwrites shared (and parent personas) on kind+key.</summary>
|
static Dictionary<string, int> PersonaRankMap(IEnumerable<string> personaChain)
|
||||||
public async Task<JArray> RetrieveAsync(string baseUrl, string query, int topK = 10, string modelOverride = null, IEnumerable<string> personaChain = null)
|
|
||||||
{
|
{
|
||||||
if (string.IsNullOrWhiteSpace(query))
|
|
||||||
{
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
lock (_lock)
|
|
||||||
{
|
|
||||||
EnsureOpen();
|
|
||||||
}
|
|
||||||
string model = string.IsNullOrWhiteSpace(modelOverride) ? _embedModel : modelOverride;
|
|
||||||
float[] q;
|
|
||||||
try
|
|
||||||
{
|
|
||||||
q = await EmbedAsync(baseUrl, model, query);
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
Logs.Debug($"AssistentMemory retrieve embed: {ex.Message}");
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
Dictionary<string, int> rank = new(StringComparer.OrdinalIgnoreCase)
|
Dictionary<string, int> rank = new(StringComparer.OrdinalIgnoreCase)
|
||||||
{
|
{
|
||||||
[SharedPersona] = 0,
|
[SharedPersona] = 0,
|
||||||
@@ -465,46 +573,219 @@ public sealed class AssistentMemory : IDisposable
|
|||||||
}
|
}
|
||||||
rank[p] = i++;
|
rank[p] = i++;
|
||||||
}
|
}
|
||||||
|
return rank;
|
||||||
|
}
|
||||||
|
|
||||||
List<(float score, int personaRank, int sourceRank, JObject row)> scored = [];
|
static string BuildFtsMatch(string query)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(query))
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
HashSet<string> tokens = new(StringComparer.OrdinalIgnoreCase);
|
||||||
|
foreach (Match m in Regex.Matches(query, @"[\p{L}\p{N}_-]{2,}"))
|
||||||
|
{
|
||||||
|
string t = m.Value.Replace("\"", "").Trim('_', '-');
|
||||||
|
if (t.Length >= 2)
|
||||||
|
{
|
||||||
|
tokens.Add(t);
|
||||||
|
}
|
||||||
|
string spaced = t.Replace('_', ' ').Replace('-', ' ');
|
||||||
|
if (!string.Equals(spaced, t, StringComparison.OrdinalIgnoreCase) && spaced.Length >= 2)
|
||||||
|
{
|
||||||
|
foreach (string p in spaced.Split(' ', StringSplitOptions.RemoveEmptyEntries))
|
||||||
|
{
|
||||||
|
if (p.Length >= 2)
|
||||||
|
{
|
||||||
|
tokens.Add(p);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (tokens.Count >= 12)
|
||||||
|
{
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (tokens.Count == 0)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return string.Join(" OR ", tokens.Select(t => $"\"{t}\""));
|
||||||
|
}
|
||||||
|
|
||||||
|
Dictionary<long, int> FtsRowRanks(string match, int limit)
|
||||||
|
{
|
||||||
|
Dictionary<long, int> ranks = [];
|
||||||
|
if (string.IsNullOrWhiteSpace(match) || !TableExists("memories_fts"))
|
||||||
|
{
|
||||||
|
return ranks;
|
||||||
|
}
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using SqliteCommand cmd = _conn.CreateCommand();
|
||||||
|
cmd.CommandText = "SELECT rowid FROM memories_fts WHERE memories_fts MATCH $q ORDER BY rank LIMIT $lim";
|
||||||
|
cmd.Parameters.AddWithValue("$q", match);
|
||||||
|
cmd.Parameters.AddWithValue("$lim", limit);
|
||||||
|
using SqliteDataReader reader = cmd.ExecuteReader();
|
||||||
|
int r = 0;
|
||||||
|
while (reader.Read())
|
||||||
|
{
|
||||||
|
ranks[reader.GetInt64(0)] = r++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Logs.Debug($"AssistentMemory FTS match: {ex.Message}");
|
||||||
|
}
|
||||||
|
return ranks;
|
||||||
|
}
|
||||||
|
|
||||||
|
static float KeyBoost(string query, string key)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(query))
|
||||||
|
{
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
string q = query.Replace('_', ' ');
|
||||||
|
string k = (key ?? "").Replace('_', ' ');
|
||||||
|
if (k.Length >= 3 && q.Contains(k, StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
return 0.22f;
|
||||||
|
}
|
||||||
|
if (k.Length >= 3 && k.Contains(q.Trim(), StringComparison.OrdinalIgnoreCase) && q.Trim().Length >= 4)
|
||||||
|
{
|
||||||
|
return 0.12f;
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
static JArray ApplyQuotas(IEnumerable<JObject> ordered, int topK, IReadOnlyDictionary<string, int> quotas)
|
||||||
|
{
|
||||||
|
Dictionary<string, int> used = new(StringComparer.OrdinalIgnoreCase);
|
||||||
|
List<JObject> picked = [];
|
||||||
|
List<JObject> overflow = [];
|
||||||
|
foreach (JObject row in ordered)
|
||||||
|
{
|
||||||
|
string kind = row["kind"]?.ToString() ?? "note";
|
||||||
|
int cap = 2;
|
||||||
|
if (quotas is not null && quotas.TryGetValue(kind, out int q))
|
||||||
|
{
|
||||||
|
cap = q;
|
||||||
|
}
|
||||||
|
else if (DefaultQuotas.TryGetValue(kind, out int d))
|
||||||
|
{
|
||||||
|
cap = d;
|
||||||
|
}
|
||||||
|
used.TryGetValue(kind, out int n);
|
||||||
|
if (n < cap)
|
||||||
|
{
|
||||||
|
picked.Add(row);
|
||||||
|
used[kind] = n + 1;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
overflow.Add(row);
|
||||||
|
}
|
||||||
|
if (picked.Count >= topK)
|
||||||
|
{
|
||||||
|
return new JArray(picked);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
foreach (JObject row in overflow)
|
||||||
|
{
|
||||||
|
if (picked.Count >= topK)
|
||||||
|
{
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
picked.Add(row);
|
||||||
|
}
|
||||||
|
return new JArray(picked);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Retrieve shared + the given persona chain. Hybrid FTS + cosine; personal overwrites shared on kind+key.</summary>
|
||||||
|
public async Task<JArray> RetrieveAsync(string baseUrl, string query, int topK = 10, string modelOverride = null, IEnumerable<string> personaChain = null, RetrieveOptions options = null)
|
||||||
|
{
|
||||||
|
options ??= new RetrieveOptions { TopK = topK };
|
||||||
|
if (options.TopK <= 0)
|
||||||
|
{
|
||||||
|
options.TopK = topK;
|
||||||
|
}
|
||||||
|
if (string.IsNullOrWhiteSpace(query))
|
||||||
|
{
|
||||||
|
return [];
|
||||||
|
}
|
||||||
lock (_lock)
|
lock (_lock)
|
||||||
{
|
{
|
||||||
EnsureOpen();
|
EnsureOpen();
|
||||||
|
}
|
||||||
|
string model = string.IsNullOrWhiteSpace(modelOverride) ? _embedModel : modelOverride;
|
||||||
|
float[] qvec = null;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
qvec = await EmbedAsync(baseUrl, model, query);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Logs.Debug($"AssistentMemory retrieve embed: {ex.Message}");
|
||||||
|
}
|
||||||
|
|
||||||
|
Dictionary<string, int> rank = PersonaRankMap(personaChain);
|
||||||
|
string kindFilter = string.IsNullOrWhiteSpace(options.KindFilter) ? null : options.KindFilter.Trim().ToLowerInvariant();
|
||||||
|
string ftsMatch = BuildFtsMatch(query);
|
||||||
|
|
||||||
|
List<(float hybrid, float cosine, int personaRank, int sourceRank, bool fts, JObject row)> scored = [];
|
||||||
|
lock (_lock)
|
||||||
|
{
|
||||||
|
EnsureOpen();
|
||||||
|
Dictionary<long, int> ftsRanks = FtsRowRanks(ftsMatch, Math.Max(40, options.TopK * 4));
|
||||||
using SqliteCommand cmd = _conn.CreateCommand();
|
using SqliteCommand cmd = _conn.CreateCommand();
|
||||||
cmd.CommandText = "SELECT kind, key, text, source, meta_json, embedding, persona FROM memories WHERE embedding IS NOT NULL";
|
cmd.CommandText = "SELECT id, kind, key, text, source, embedding, persona FROM memories";
|
||||||
using SqliteDataReader reader = cmd.ExecuteReader();
|
using SqliteDataReader reader = cmd.ExecuteReader();
|
||||||
while (reader.Read())
|
while (reader.Read())
|
||||||
{
|
{
|
||||||
|
long id = reader.GetInt64(0);
|
||||||
|
string kind = reader.GetString(1);
|
||||||
|
if (kindFilter is not null && !string.Equals(kind, kindFilter, StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
string persona = reader.IsDBNull(6) ? SharedPersona : reader.GetString(6) ?? SharedPersona;
|
string persona = reader.IsDBNull(6) ? SharedPersona : reader.GetString(6) ?? SharedPersona;
|
||||||
if (!rank.TryGetValue(persona, out int personaRank))
|
if (!rank.TryGetValue(persona, out int personaRank))
|
||||||
{
|
{
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
float[] emb = BytesToFloats(reader.IsDBNull(5) ? null : (byte[])reader.GetValue(5));
|
string key = reader.GetString(2);
|
||||||
float score = Cosine(q, emb);
|
string text = reader.GetString(3);
|
||||||
if (float.IsNegativeInfinity(score))
|
string source = reader.GetString(4);
|
||||||
|
float cosine = 0;
|
||||||
|
if (qvec is not null && !reader.IsDBNull(5))
|
||||||
{
|
{
|
||||||
continue;
|
cosine = Cosine(qvec, BytesToFloats((byte[])reader.GetValue(5)));
|
||||||
|
if (float.IsNegativeInfinity(cosine))
|
||||||
|
{
|
||||||
|
cosine = 0;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
string kind = reader.GetString(0);
|
bool ftsHit = ftsRanks.TryGetValue(id, out int ftsRank);
|
||||||
string key = reader.GetString(1);
|
float ftsBoost = ftsHit ? 0.28f * (1f - (ftsRank / 50f)) : 0;
|
||||||
string source = reader.GetString(3);
|
float hybrid = cosine + ftsBoost + KeyBoost(query, key);
|
||||||
bool shared = persona == SharedPersona;
|
bool shared = persona == SharedPersona;
|
||||||
scored.Add((score, personaRank, SourceRank(source), new JObject
|
scored.Add((hybrid, cosine, personaRank, SourceRank(source), ftsHit, new JObject
|
||||||
{
|
{
|
||||||
["kind"] = kind,
|
["kind"] = kind,
|
||||||
["key"] = key,
|
["key"] = key,
|
||||||
["text"] = reader.GetString(2),
|
["text"] = text,
|
||||||
["source"] = source,
|
["source"] = source,
|
||||||
["scope"] = shared ? "shared" : "personal",
|
["scope"] = shared ? "shared" : "personal",
|
||||||
["persona"] = shared ? "shared" : persona,
|
["persona"] = shared ? "shared" : persona,
|
||||||
["score"] = Math.Round(score, 4),
|
["score"] = Math.Round(hybrid, 4),
|
||||||
|
["cosine"] = Math.Round(cosine, 4),
|
||||||
|
["fts"] = ftsHit,
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Personal (and later parents) overwrite shared on the same kind+key; user beats bundled.
|
Dictionary<string, (float hybrid, float cosine, int personaRank, int sourceRank, bool fts, JObject row)> best = new(StringComparer.OrdinalIgnoreCase);
|
||||||
Dictionary<string, (float score, int personaRank, int sourceRank, JObject row)> best = new(StringComparer.OrdinalIgnoreCase);
|
|
||||||
foreach (var item in scored)
|
foreach (var item in scored)
|
||||||
{
|
{
|
||||||
string id = $"{item.row["kind"]}\n{item.row["key"]}";
|
string id = $"{item.row["kind"]}\n{item.row["key"]}";
|
||||||
@@ -512,7 +793,7 @@ public sealed class AssistentMemory : IDisposable
|
|||||||
{
|
{
|
||||||
if (item.personaRank < cur.personaRank
|
if (item.personaRank < cur.personaRank
|
||||||
|| (item.personaRank == cur.personaRank && item.sourceRank < cur.sourceRank)
|
|| (item.personaRank == cur.personaRank && item.sourceRank < cur.sourceRank)
|
||||||
|| (item.personaRank == cur.personaRank && item.sourceRank == cur.sourceRank && item.score <= cur.score))
|
|| (item.personaRank == cur.personaRank && item.sourceRank == cur.sourceRank && item.hybrid <= cur.hybrid))
|
||||||
{
|
{
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -520,7 +801,79 @@ public sealed class AssistentMemory : IDisposable
|
|||||||
best[id] = item;
|
best[id] = item;
|
||||||
}
|
}
|
||||||
|
|
||||||
return new JArray(best.Values.OrderByDescending(s => s.score).Take(Math.Clamp(topK, 1, 30)).Select(s => s.row));
|
float min = options.MinScore;
|
||||||
|
IEnumerable<JObject> ordered = best.Values
|
||||||
|
.Where(s => s.fts || s.hybrid >= min || s.cosine >= min)
|
||||||
|
.OrderByDescending(s => s.hybrid)
|
||||||
|
.Select(s => s.row);
|
||||||
|
|
||||||
|
int k = Math.Clamp(options.TopK, 1, 30);
|
||||||
|
if (options.ApplyQuotas)
|
||||||
|
{
|
||||||
|
return ApplyQuotas(ordered, k, options.Quotas ?? DefaultQuotas);
|
||||||
|
}
|
||||||
|
return new JArray(ordered.Take(k));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Exact kind+key read with the same personal-over-shared overlay as retrieve.</summary>
|
||||||
|
public JObject Get(string kind, string key, IEnumerable<string> personaChain = null)
|
||||||
|
{
|
||||||
|
kind = (kind ?? "note").Trim().ToLowerInvariant();
|
||||||
|
key = (key ?? "").Trim();
|
||||||
|
if (string.IsNullOrWhiteSpace(key))
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
Dictionary<string, int> rank = PersonaRankMap(personaChain);
|
||||||
|
JObject best = null;
|
||||||
|
int bestPersona = -1, bestSource = -1;
|
||||||
|
lock (_lock)
|
||||||
|
{
|
||||||
|
EnsureOpen();
|
||||||
|
using SqliteCommand cmd = _conn.CreateCommand();
|
||||||
|
cmd.CommandText = "SELECT kind, key, text, source, persona, updated FROM memories WHERE kind = $kind AND key = $key";
|
||||||
|
cmd.Parameters.AddWithValue("$kind", kind);
|
||||||
|
cmd.Parameters.AddWithValue("$key", key);
|
||||||
|
using SqliteDataReader reader = cmd.ExecuteReader();
|
||||||
|
while (reader.Read())
|
||||||
|
{
|
||||||
|
string persona = reader.IsDBNull(4) ? SharedPersona : reader.GetString(4) ?? SharedPersona;
|
||||||
|
if (!rank.TryGetValue(persona, out int personaRank))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
int src = SourceRank(reader.GetString(3));
|
||||||
|
if (best is not null && (personaRank < bestPersona || (personaRank == bestPersona && src <= bestSource)))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
bestPersona = personaRank;
|
||||||
|
bestSource = src;
|
||||||
|
bool shared = persona == SharedPersona;
|
||||||
|
best = new JObject
|
||||||
|
{
|
||||||
|
["kind"] = reader.GetString(0),
|
||||||
|
["key"] = reader.GetString(1),
|
||||||
|
["text"] = reader.GetString(2),
|
||||||
|
["source"] = reader.GetString(3),
|
||||||
|
["scope"] = shared ? "shared" : "personal",
|
||||||
|
["persona"] = shared ? "shared" : persona,
|
||||||
|
["updated"] = reader.IsDBNull(5) ? 0 : reader.GetInt64(5),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return best;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<JArray> SearchAsync(string baseUrl, string query, string kind, int topK, string modelOverride, IEnumerable<string> personaChain)
|
||||||
|
{
|
||||||
|
return await RetrieveAsync(baseUrl, query, topK, modelOverride, personaChain, new RetrieveOptions
|
||||||
|
{
|
||||||
|
TopK = Math.Clamp(topK, 1, 30),
|
||||||
|
MinScore = 0.18f,
|
||||||
|
KindFilter = kind,
|
||||||
|
ApplyQuotas = false,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
public JArray ListAll(int limit = 200)
|
public JArray ListAll(int limit = 200)
|
||||||
|
|||||||
@@ -173,6 +173,75 @@ public partial class SwarmAssistentExtension
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task<JObject> AssistentSearchMemory(Session session, string query, string kind = null, int limit = 10, string persona = null, string baseUrl = null, string embed_model = null)
|
||||||
|
{
|
||||||
|
if (Memory is null)
|
||||||
|
{
|
||||||
|
return new JObject { ["error"] = "memory not ready" };
|
||||||
|
}
|
||||||
|
query = (query ?? "").Trim();
|
||||||
|
if (query.Length < 2)
|
||||||
|
{
|
||||||
|
return new JObject { ["error"] = "query required" };
|
||||||
|
}
|
||||||
|
string pid = AssistentConfig.SafeId(persona) ?? Config.DefaultPersonaId();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
JArray rows = await Memory.SearchAsync(MemoryBaseUrl(baseUrl), query, kind, limit, MemoryEmbedModel(embed_model), Config.PersonaExtendsChain(pid));
|
||||||
|
return new JObject { ["success"] = true, ["query"] = query, ["kind"] = kind ?? "", ["memories"] = rows };
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
return new JObject { ["error"] = $"memory search: {ex.Message}" };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<JObject> AssistentGetMemory(Session session, string kind, string key, string persona = null)
|
||||||
|
{
|
||||||
|
await Task.CompletedTask;
|
||||||
|
if (Memory is null)
|
||||||
|
{
|
||||||
|
return new JObject { ["error"] = "memory not ready" };
|
||||||
|
}
|
||||||
|
string pid = AssistentConfig.SafeId(persona) ?? Config.DefaultPersonaId();
|
||||||
|
JObject row = Memory.Get(kind, key, Config.PersonaExtendsChain(pid));
|
||||||
|
if (row is null)
|
||||||
|
{
|
||||||
|
return new JObject { ["success"] = true, ["missing"] = true, ["kind"] = kind, ["key"] = key };
|
||||||
|
}
|
||||||
|
return new JObject { ["success"] = true, ["memory"] = row };
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<JObject> AssistentLookupTags(Session session, string query, int limit = 20)
|
||||||
|
{
|
||||||
|
await Task.CompletedTask;
|
||||||
|
if (Memory is null)
|
||||||
|
{
|
||||||
|
return new JObject { ["error"] = "memory not ready" };
|
||||||
|
}
|
||||||
|
query = (query ?? "").Trim();
|
||||||
|
if (query.Length < 1)
|
||||||
|
{
|
||||||
|
return new JObject { ["error"] = "query required" };
|
||||||
|
}
|
||||||
|
try
|
||||||
|
{
|
||||||
|
JArray tags = Memory.LookupTags(query, limit);
|
||||||
|
return new JObject
|
||||||
|
{
|
||||||
|
["success"] = true,
|
||||||
|
["query"] = query,
|
||||||
|
["tags"] = tags,
|
||||||
|
["indexed"] = Memory.TagCount(),
|
||||||
|
["csv"] = Memory.FindAutocompleteCsv() ?? "",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
return new JObject { ["error"] = $"tag lookup: {ex.Message}" };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>The gpu-rent wanted queue (models pending the next <c>up</c>) — count + entries.</summary>
|
/// <summary>The gpu-rent wanted queue (models pending the next <c>up</c>) — count + entries.</summary>
|
||||||
public async Task<JObject> AssistentListWanted(Session session)
|
public async Task<JObject> AssistentListWanted(Session session)
|
||||||
{
|
{
|
||||||
|
|||||||
+61
-2
@@ -19,6 +19,7 @@ public partial class SwarmAssistentExtension
|
|||||||
"snapshot_generate", "select_slot", "aspect", "images", "batch", "vary", "lock_seed",
|
"snapshot_generate", "select_slot", "aspect", "images", "batch", "vary", "lock_seed",
|
||||||
"creativity", "intensity", "complexity", "movement",
|
"creativity", "intensity", "complexity", "movement",
|
||||||
"clear_prompt_images", "slot_to_prompt_image", "pack", "memories", "memory",
|
"clear_prompt_images", "slot_to_prompt_image", "pack", "memories", "memory",
|
||||||
|
"memory_query", "memory_kind", "tag_query",
|
||||||
];
|
];
|
||||||
|
|
||||||
static bool HasValue(JObject obj, string key)
|
static bool HasValue(JObject obj, string key)
|
||||||
@@ -91,8 +92,66 @@ public partial class SwarmAssistentExtension
|
|||||||
return string.IsNullOrWhiteSpace(q) ? null : q;
|
return string.IsNullOrWhiteSpace(q) ? null : q;
|
||||||
}
|
}
|
||||||
|
|
||||||
static bool WantsCivitaiSearch(JObject patch)
|
static bool ActionsContain(JObject patch, string action)
|
||||||
{
|
{
|
||||||
return !string.IsNullOrWhiteSpace(ExtractSearchQuery(patch));
|
if (patch?["actions"] is not JArray acts)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
foreach (JToken a in acts)
|
||||||
|
{
|
||||||
|
if (string.Equals(a?.ToString(), action, StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static string ExtractMemoryQuery(JObject patch)
|
||||||
|
{
|
||||||
|
string q = patch?["memory_query"]?.ToString()?.Trim();
|
||||||
|
if (!string.IsNullOrWhiteSpace(q))
|
||||||
|
{
|
||||||
|
return q;
|
||||||
|
}
|
||||||
|
return ActionsContain(patch, "memory_search") ? ExtractSearchQuery(patch) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
static string ExtractTagQuery(JObject patch)
|
||||||
|
{
|
||||||
|
string q = patch?["tag_query"]?.ToString()?.Trim();
|
||||||
|
if (!string.IsNullOrWhiteSpace(q))
|
||||||
|
{
|
||||||
|
return q;
|
||||||
|
}
|
||||||
|
return ActionsContain(patch, "lookup_tags") ? ExtractSearchQuery(patch) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
static string NextToolHop(JObject patch)
|
||||||
|
{
|
||||||
|
if (patch is null)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (ActionsContain(patch, "memory_get"))
|
||||||
|
{
|
||||||
|
return "memory_get";
|
||||||
|
}
|
||||||
|
if (ActionsContain(patch, "memory_search") || !string.IsNullOrWhiteSpace(patch["memory_query"]?.ToString()))
|
||||||
|
{
|
||||||
|
return "memory_search";
|
||||||
|
}
|
||||||
|
if (ActionsContain(patch, "lookup_tags") || !string.IsNullOrWhiteSpace(patch["tag_query"]?.ToString()))
|
||||||
|
{
|
||||||
|
return "lookup_tags";
|
||||||
|
}
|
||||||
|
if (ActionsContain(patch, "search_civitai") || !string.IsNullOrWhiteSpace(ExtractSearchQuery(patch)))
|
||||||
|
{
|
||||||
|
return "civitai";
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
static bool WantsCivitaiSearch(JObject patch) => NextToolHop(patch) == "civitai";
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -57,7 +57,7 @@ public partial class SwarmAssistentExtension
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Single-token chat so the model is resident again by the time the user types.</summary>
|
/// <summary>Single-token chat so the model is resident again by the time the user types.</summary>
|
||||||
public async Task<JObject> AssistentWarmLlm(Session session, string baseUrl, string model, string persona = null)
|
public async Task<JObject> AssistentWarmLlm(Session session, string baseUrl, string model)
|
||||||
{
|
{
|
||||||
string root = NormalizeBaseUrl(baseUrl);
|
string root = NormalizeBaseUrl(baseUrl);
|
||||||
string name = (model ?? "").Trim();
|
string name = (model ?? "").Trim();
|
||||||
|
|||||||
@@ -10,6 +10,17 @@
|
|||||||
"default_persona": "neutral",
|
"default_persona": "neutral",
|
||||||
"embed_model": "nomic-embed-text",
|
"embed_model": "nomic-embed-text",
|
||||||
"memory_top_k": 10,
|
"memory_top_k": 10,
|
||||||
|
"memory_min_score": 0.32,
|
||||||
|
"max_tool_hops": 4,
|
||||||
|
"tag_lookup_limit": 20,
|
||||||
|
"memory_quotas": {
|
||||||
|
"card": 3,
|
||||||
|
"lora": 3,
|
||||||
|
"pitfall": 3,
|
||||||
|
"path": 2,
|
||||||
|
"note": 4,
|
||||||
|
"model": 2
|
||||||
|
},
|
||||||
"seed_version": 2,
|
"seed_version": 2,
|
||||||
"gate": {
|
"gate": {
|
||||||
"architecture": "krea2",
|
"architecture": "krea2",
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ When instructions conflict, apply this order (highest wins):
|
|||||||
3. **Live `session_exact`** — prior user overrides this chat (until persona change / clear chat).
|
3. **Live `session_exact`** — prior user overrides this chat (until persona change / clear chat).
|
||||||
4. **Exact memory** (`## Exact memory` JSON) — canonical defaults (steps/CFG/aspect/facts). Persona overlays are already merged into it.
|
4. **Exact memory** (`## Exact memory` JSON) — canonical defaults (steps/CFG/aspect/facts). Persona overlays are already merged into it.
|
||||||
5. **Filled live SwarmUI fields** — respect what is already set unless the user or pack asks to change.
|
5. **Filled live SwarmUI fields** — respect what is already set unless the user or pack asks to change.
|
||||||
6. **`memory_hits` (vector RAG)** — notes, pitfalls, LoRA blurbs. Shared hits apply to every persona; personal hits are this persona only and overwrite shared on the same kind+key. Never override exact numbers or the user’s param request.
|
6. **`memory_hits` (hybrid FTS + vector RAG)** — notes, pitfalls, LoRA blurbs. Shared hits apply to every persona; personal hits overwrite shared on the same kind+key. Never override exact numbers or the user’s param request. For a missing exact row use `memory_get`; for a second search use `memory_search`; for Danbooru spelling/aliases use `lookup_tags` (do not dump tag soup into Krea prompts).
|
||||||
7. Guesses — last resort only.
|
7. Guesses — last resort only.
|
||||||
|
|
||||||
Exact = encyclopedia of defaults. RAG = soft notes. Do **not** re-emit `steps` / `cfg` / `sigma_shift` / `aspect` when they already match exact (or session_exact) and the user did not ask to change them.
|
Exact = encyclopedia of defaults. RAG = soft notes. Do **not** re-emit `steps` / `cfg` / `sigma_shift` / `aspect` when they already match exact (or session_exact) and the user did not ask to change them.
|
||||||
@@ -25,7 +25,7 @@ A JSON block named "Live SwarmUI context" is attached. Treat it as ground truth
|
|||||||
- Use only LoRAs listed in `available_loras` / `enabled_loras` (exact `name`), or Civitai search candidates.
|
- Use only LoRAs listed in `available_loras` / `enabled_loras` (exact `name`), or Civitai search candidates.
|
||||||
- Prefer listed `trigger_phrase` / `triggers` — **never invent** trigger words.
|
- Prefer listed `trigger_phrase` / `triggers` — **never invent** trigger words.
|
||||||
- `session_exact` / `recommended_params` — session overrides and defaults (Exact KV is in the system block above).
|
- `session_exact` / `recommended_params` — session overrides and defaults (Exact KV is in the system block above).
|
||||||
- `memory_hits` are retrieved notes (LoRA tips, pitfalls). Each hit has `scope` (`shared`|`personal`). Trust them over guesses, but **not** over Exact or the user.
|
- `memory_hits` are retrieved notes (hybrid FTS+vector). Each hit has `scope` (`shared`|`personal`). Trust them over guesses, but **not** over Exact or the user.
|
||||||
- `has_vision_image` — if false, do not invent what the image looks like; emit `look_at` first when you need to see it.
|
- `has_vision_image` — if false, do not invent what the image looks like; emit `look_at` first when you need to see it.
|
||||||
- `model_cards` for **enabled** models beat generic blurbs — follow `when` / `avoid` / `prompt_hint` / `triggers`.
|
- `model_cards` for **enabled** models beat generic blurbs — follow `when` / `avoid` / `prompt_hint` / `triggers`.
|
||||||
- `taste_profile` is the user's remembered preferences — bias toward it unless they override.
|
- `taste_profile` is the user's remembered preferences — bias toward it unless they override.
|
||||||
@@ -77,6 +77,9 @@ A JSON block named "Live SwarmUI context" is attached. Treat it as ground truth
|
|||||||
"pack": null,
|
"pack": null,
|
||||||
"actions": ["generate"],
|
"actions": ["generate"],
|
||||||
"search_query": null,
|
"search_query": null,
|
||||||
|
"memory_query": null,
|
||||||
|
"memory_kind": null,
|
||||||
|
"tag_query": null,
|
||||||
"memories": [{"kind": "lora", "key": "name", "text": "fact", "scope": "personal"}],
|
"memories": [{"kind": "lora", "key": "name", "text": "fact", "scope": "personal"}],
|
||||||
"notes": "one-line why"
|
"notes": "one-line why"
|
||||||
}
|
}
|
||||||
@@ -91,7 +94,7 @@ A JSON block named "Live SwarmUI context" is attached. Treat it as ground truth
|
|||||||
- `vary: true` — new random seed. `lock_seed: true` — reuse current seed.
|
- `vary: true` — new random seed. `lock_seed: true` — reuse current seed.
|
||||||
- `pack` — switch active prompt pack for a follow-up hop.
|
- `pack` — switch active prompt pack for a follow-up hop.
|
||||||
- Do not invent model or LoRA filenames.
|
- Do not invent model or LoRA filenames.
|
||||||
- Memory: `actions` may include `memory_upsert` or `memory_forget` with `memories: [{kind,key,text,scope}]`. Default scope is personal (this persona). `"scope":"shared"` is visible to all personas; personal never copies into shared.
|
- Memory: `memory_upsert` / `memory_forget` with `memories: [{kind,key,text,scope}]`. Default scope is personal. Tools: `memory_get` + kind/key, `memory_search` + `memory_query`, `lookup_tags` + `tag_query` (Danbooru csv — spelling only, not prompt soup).
|
||||||
|
|
||||||
### Actions (auto-safe)
|
### Actions (auto-safe)
|
||||||
|
|
||||||
@@ -99,5 +102,7 @@ A JSON block named "Live SwarmUI context" is attached. Treat it as ground truth
|
|||||||
- `"search_civitai"` — Civitai search; user Confirms downloads.
|
- `"search_civitai"` — Civitai search; user Confirms downloads.
|
||||||
- `"interrupt"` — stop generation.
|
- `"interrupt"` — stop generation.
|
||||||
- `"memory_upsert"` / `"memory_forget"` — write or delete vector memory (personal by default; `scope: "shared"` for the common store).
|
- `"memory_upsert"` / `"memory_forget"` — write or delete vector memory (personal by default; `scope: "shared"` for the common store).
|
||||||
|
- `"memory_get"` / `"memory_search"` — hop: exact row or hybrid search.
|
||||||
|
- `"lookup_tags"` — hop: Danbooru csv (aliases/counts). Do not emit tag soup for Krea.
|
||||||
- `look_at: ["generate", "ref1"]` — vision hop.
|
- `look_at: ["generate", "ref1"]` — vision hop.
|
||||||
- Pure Q&A with no change: omit the JSON patch.
|
- Pure Q&A with no change: omit the JSON patch.
|
||||||
|
|||||||
@@ -1,26 +1,34 @@
|
|||||||
# Skill: memory
|
# Skill: memory
|
||||||
|
|
||||||
You have two memory layers:
|
You have three memory tools:
|
||||||
|
|
||||||
1. **Exact memory** (`## Exact memory` + live `exact` / `session_exact`) — canonical KV defaults (generation params, aspect table, architecture facts). Always prefer Exact over RAG for numbers and defaults.
|
1. **Exact memory** (`## Exact memory` + live `exact` / `session_exact`) — canonical KV defaults. Always prefer Exact over RAG for numbers.
|
||||||
2. **Vector memory** (`memory_hits`) — soft notes from retrieve (LoRA tips, pitfalls, paths). Hits are **shared + this persona**. `scope: "personal"` overwrites `scope: "shared"` on the same `kind`+`key`. Other personas never see your personal rows.
|
2. **Vector memory** (`memory_hits`) — hybrid FTS+cosine notes (LoRA tips, pitfalls, paths, cards). Shared + this persona; personal overwrites shared on the same `kind`+`key`.
|
||||||
|
3. **Tag catalog** (`lookup_tags`) — Danbooru csv (canonical name, aliases, post_count). **Not** RAG. Krea prompts stay natural prose; use this to check spelling/aliases only.
|
||||||
|
|
||||||
## Priority
|
## Priority
|
||||||
|
|
||||||
User (this turn) > `session_exact` > Exact KV > filled live fields > `memory_hits` > guesses. Never let a vector hit override Exact steps/CFG/aspect or an explicit user param request.
|
User (this turn) > `session_exact` > Exact KV > filled live fields > `memory_hits` > guesses. Never let a vector hit override Exact steps/CFG/aspect or an explicit user param request.
|
||||||
|
|
||||||
|
## Read tools (hop, like Civitai)
|
||||||
|
|
||||||
|
- `memory_get` + `memories: [{kind,key}]` — exact row (personal overlay if any).
|
||||||
|
- `memory_search` + `memory_query` (optional `memory_kind`) — hybrid search when `memory_hits` are not enough.
|
||||||
|
- `lookup_tags` + `tag_query` — csv lookup. Do **not** paste tag soup into the prompt.
|
||||||
|
|
||||||
|
Omit the tool action on the follow-up turn once you have results.
|
||||||
|
|
||||||
## When to write (vector only)
|
## When to write (vector only)
|
||||||
|
|
||||||
- Durable facts about a LoRA/checkpoint (when it works, what it breaks, good weight).
|
- Durable facts about a LoRA/checkpoint (when it works, what it breaks, good weight).
|
||||||
- Bad paths / pitfalls you discovered this session.
|
- Bad paths / pitfalls you discovered this session.
|
||||||
- Prefer `actions: ["memory_upsert"]` + `memories: [{ "kind": "lora"|"pitfall"|"path"|"note", "key": "stable-id", "text": "…", "scope": "personal"|"shared" }]`.
|
- Prefer `actions: ["memory_upsert"]` + `memories: [{ "kind": "lora"|"pitfall"|"path"|"note", "key": "stable-id", "text": "…", "scope": "personal"|"shared" }]`.
|
||||||
- Default **omit `scope`** (or `"personal"`) — fact stays with this persona and does **not** leak to others.
|
- Default **omit `scope`** (or `"personal"`). `"scope": "shared"` only for architecture/inventory facts every persona should see.
|
||||||
- Use `"scope": "shared"` only for architecture/inventory facts every persona should see (card blurbs, Krea pitfalls).
|
|
||||||
|
|
||||||
## When not to write
|
## When not to write
|
||||||
|
|
||||||
- Do not dump Exact defaults into vector memory — they already live in `exact.json`.
|
- Do not dump Exact defaults or the full inventory into vector memory.
|
||||||
- Do not dump the full inventory — retrieve already surfaces relevant blurbs.
|
- Do not store the user's taste profile (`taste.json`).
|
||||||
- Do not store the user's taste profile (that is `taste_profile` / taste.json).
|
- Do not upsert trivia already in `memory_hits`.
|
||||||
- Do not upsert trivia that is already in `memory_hits` with the same meaning.
|
- Do not upsert Danbooru tags — the csv catalog already has them.
|
||||||
- `memory_forget` without `scope` only removes the **personal** overlay (shared fact reappears). Use `"scope": "shared"` to delete a shared row.
|
- `memory_forget` without `scope` only removes the personal overlay.
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
SwarmUI extension for **collaborative Krea 2** prompting via **Ollama**: chat + board (Generate | Refs tabs), LoRA chips, **persona presets** (`Config/personas/`), **vector memory**, model cards with Civitai fetch, img2img/inpaint, slash commands, auto Generate.
|
SwarmUI extension for **collaborative Krea 2** prompting via **Ollama**: chat + board (Generate | Refs tabs), LoRA chips, **persona presets** (`Config/personas/`), **vector memory**, model cards with Civitai fetch, img2img/inpaint, slash commands, auto Generate.
|
||||||
|
|
||||||
**Version 0.8.1** — Chats live on disk (`Assistent/chats/`), the chat model is **parked out of VRAM** before every Generate, memory + wanted queue are editable in ⚙, Ollama health sits in the chat header. Vector memory is shared + personal: personal never leaks into shared; shared is visible to every persona; personal overwrites the same kind+key.
|
**Version 0.8.2** — Hybrid memory (FTS5 + cosine, kind quotas, min_score) plus model hops `memory_get` / `memory_search` / `lookup_tags`. Danbooru csv is a shared FTS catalog (no embeddings); Krea prompts stay prose.
|
||||||
|
|
||||||
## Layout
|
## Layout
|
||||||
|
|
||||||
@@ -48,7 +48,8 @@ Two layers in `memory/assistent.sqlite` (`persona` column; empty = shared):
|
|||||||
|
|
||||||
- **Shared** — `Config/_base/memory-seed/`, model cards, `scope: "shared"` upserts. Visible to every persona.
|
- **Shared** — `Config/_base/memory-seed/`, model cards, `scope: "shared"` upserts. Visible to every persona.
|
||||||
- **Personal** — `Config/personas/<id>/memory-seed/` and chat upserts (default). Never copied into shared. Other personas do not retrieve it.
|
- **Personal** — `Config/personas/<id>/memory-seed/` and chat upserts (default). Never copied into shared. Other personas do not retrieve it.
|
||||||
- Retrieve = shared ∪ this persona (and `extends` parents). Same `kind`+`key`: personal overwrites parent overwrites shared. Forget without `scope` only drops the personal overlay.
|
- Retrieve = shared ∪ this persona (and `extends` parents). Hybrid **FTS5 + cosine**, kind quotas (e.g. 3 cards / 3 pitfalls / 4 notes), `min_score`. Same `kind`+`key`: personal overwrites parent overwrites shared.
|
||||||
|
- Tools: `memory_get`, `memory_search`, `lookup_tags` (Danbooru csv in `Data/Autocompletions`, FTS, **no embeddings**).
|
||||||
- SQLite + Ollama `/api/embed` (default `nomic-embed-text`, pick in ⚙)
|
- SQLite + Ollama `/api/embed` (default `nomic-embed-text`, pick in ⚙)
|
||||||
- Soft notes only — Exact and the user beat RAG for params
|
- Soft notes only — Exact and the user beat RAG for params
|
||||||
- ⚙ → **Память** lists every row (scope · source · date) with a per-row forget; bundled rows are read-only because reseed brings them back
|
- ⚙ → **Память** lists every row (scope · source · date) with a per-row forget; bundled rows are read-only because reseed brings them back
|
||||||
@@ -133,8 +134,10 @@ Restart / rebuild SwarmUI after clone. gpu-rent: `seed-extensions` + restart.
|
|||||||
| `AssistentEnqueueWanted` / `AssistentListWanted` | Wanted YAML queue (write / read + count) |
|
| `AssistentEnqueueWanted` / `AssistentListWanted` | Wanted YAML queue (write / read + count) |
|
||||||
| `AssistentGetTaste` / `AssistentSaveTaste` | Persistent taste profile |
|
| `AssistentGetTaste` / `AssistentSaveTaste` | Persistent taste profile |
|
||||||
| `AssistentSearchCivitai` | Civitai LoRA search |
|
| `AssistentSearchCivitai` | Civitai LoRA search |
|
||||||
| `AssistentChat` / `AssistentChatWS` | Chat (+ memory retrieve + Civitai hop) |
|
| `AssistentChat` / `AssistentChatWS` | Chat (+ hybrid memory + Civitai/tag hops) |
|
||||||
| `AssistentListMemory` / `AssistentUpsertMemory` / `AssistentForgetMemory` | Vector store (optional `scope` / `persona`) |
|
| `AssistentListMemory` / `AssistentUpsertMemory` / `AssistentForgetMemory` | Vector store (optional `scope` / `persona`) |
|
||||||
|
| `AssistentSearchMemory` / `AssistentGetMemory` | Hybrid search / exact kind+key |
|
||||||
|
| `AssistentLookupTags` | Danbooru csv FTS (no embeddings) |
|
||||||
| `AssistentListChats` / `AssistentGetChat` / `AssistentSaveChat` / `AssistentDeleteChat` | `Assistent/chats/<id>.json` |
|
| `AssistentListChats` / `AssistentGetChat` / `AssistentSaveChat` / `AssistentDeleteChat` | `Assistent/chats/<id>.json` |
|
||||||
| `AssistentGetUiState` / `AssistentSaveUiState` | `Assistent/ui-state.json` |
|
| `AssistentGetUiState` / `AssistentSaveUiState` | `Assistent/ui-state.json` |
|
||||||
| `AssistentParkLlm` / `AssistentWarmLlm` | Unload / reload the chat model in VRAM |
|
| `AssistentParkLlm` / `AssistentWarmLlm` | Unload / reload the chat model in VRAM |
|
||||||
|
|||||||
+16
-19
@@ -37,7 +37,7 @@ public partial class SwarmAssistentExtension : Extension
|
|||||||
ExtensionAuthor = "mrleo1nid";
|
ExtensionAuthor = "mrleo1nid";
|
||||||
Description = "Collaborative Krea 2 assistant: Ollama chat, persona presets, vector memory, model cards, Generate loop.";
|
Description = "Collaborative Krea 2 assistant: Ollama chat, persona presets, vector memory, model cards, Generate loop.";
|
||||||
License = "MIT";
|
License = "MIT";
|
||||||
Version = "0.8.1";
|
Version = "0.8.3";
|
||||||
Tags = ["tabs", "ui", "llm", "ollama", "krea", "inpaint", "memory"];
|
Tags = ["tabs", "ui", "llm", "ollama", "krea", "inpaint", "memory"];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -73,8 +73,11 @@ public partial class SwarmAssistentExtension : Extension
|
|||||||
API.RegisterAPICall(AssistentListMemory, false, PermUse);
|
API.RegisterAPICall(AssistentListMemory, false, PermUse);
|
||||||
API.RegisterAPICall(AssistentUpsertMemory, true, PermUse);
|
API.RegisterAPICall(AssistentUpsertMemory, true, PermUse);
|
||||||
API.RegisterAPICall(AssistentForgetMemory, true, PermUse);
|
API.RegisterAPICall(AssistentForgetMemory, true, PermUse);
|
||||||
|
API.RegisterAPICall(AssistentSearchMemory, false, PermUse);
|
||||||
|
API.RegisterAPICall(AssistentGetMemory, false, PermUse);
|
||||||
|
API.RegisterAPICall(AssistentLookupTags, false, PermUse);
|
||||||
API.RegisterAPICall(AssistentListWanted, false, PermUse);
|
API.RegisterAPICall(AssistentListWanted, false, PermUse);
|
||||||
Logs.Init("Swarm Assistent extension loaded (disk chats + park LLM + memory UI)");
|
Logs.Init("Swarm Assistent extension loaded (sqlite chats/kv + park LLM + memory UI)");
|
||||||
}
|
}
|
||||||
|
|
||||||
int CfgInt(string key, int fallback)
|
int CfgInt(string key, int fallback)
|
||||||
@@ -138,10 +141,6 @@ public partial class SwarmAssistentExtension : Extension
|
|||||||
return Environment.CurrentDirectory;
|
return Environment.CurrentDirectory;
|
||||||
}
|
}
|
||||||
|
|
||||||
string PersonasOverlayJsonPath() => Path.Combine(DataRoot(), "Assistent", "personas.json");
|
|
||||||
|
|
||||||
string TasteJsonPath() => Path.Combine(DataRoot(), "Assistent", "taste.json");
|
|
||||||
|
|
||||||
public string ReadPackFile(string name)
|
public string ReadPackFile(string name)
|
||||||
{
|
{
|
||||||
return Config?.LoadPackPrompt(Config.DefaultPersonaId(), name);
|
return Config?.LoadPackPrompt(Config.DefaultPersonaId(), name);
|
||||||
@@ -229,19 +228,13 @@ public partial class SwarmAssistentExtension : Extension
|
|||||||
public async Task<JObject> AssistentGetTaste(Session session)
|
public async Task<JObject> AssistentGetTaste(Session session)
|
||||||
{
|
{
|
||||||
await Task.CompletedTask;
|
await Task.CompletedTask;
|
||||||
string path = TasteJsonPath();
|
|
||||||
if (!File.Exists(path))
|
|
||||||
{
|
|
||||||
return new JObject { ["success"] = true, ["taste"] = null };
|
|
||||||
}
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
JObject taste = JObject.Parse(File.ReadAllText(path, Encoding.UTF8));
|
return new JObject { ["success"] = true, ["taste"] = Memory.GetKvObject(AssistentMemory.KvTaste) };
|
||||||
return new JObject { ["success"] = true, ["taste"] = taste };
|
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
return new JObject { ["error"] = $"taste.json: {ex.Message}" };
|
return new JObject { ["error"] = $"taste: {ex.Message}" };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -256,10 +249,14 @@ public partial class SwarmAssistentExtension : Extension
|
|||||||
{
|
{
|
||||||
taste["updated"] = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
|
taste["updated"] = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
|
||||||
}
|
}
|
||||||
string dir = Path.Combine(DataRoot(), "Assistent");
|
try
|
||||||
Directory.CreateDirectory(dir);
|
{
|
||||||
string path = TasteJsonPath();
|
Memory.SetKvObject(AssistentMemory.KvTaste, taste);
|
||||||
File.WriteAllText(path, taste.ToString(Newtonsoft.Json.Formatting.Indented), Encoding.UTF8);
|
return new JObject { ["success"] = true, ["path"] = "Assistent/memory/assistent.sqlite" };
|
||||||
return new JObject { ["success"] = true, ["path"] = path };
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
return new JObject { ["error"] = $"taste save: {ex.Message}" };
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user