Files
DumiFlex 87dd6ab12c feat(ai): add an "About my library" note every AI request reads (#195)
A short note (up to 600 characters) in Settings > AI assistant > About my
library. The chat, every Draft with AI panel and the pack builder read it
after their own instructions, and it wins over how existing items are
written. A chat keeps the note it opened with, so an edit never changes an
open chat's prompt. Suggest drafts a note from the library summary and
sample option text for the user to edit; nothing is saved until Save.

The note is stored with the shared AI settings (ai.json), not per model,
and a note that asks for a minor or non-consent in a sexual context is
refused like a pack request.
2026-10-29 14:08:34 +01:00

580 lines
28 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""What a chat turn sends the model: step asks, tail notes (permissions,
lazy tool groups, vocab, canvas), the context window, the fixed prefix,
compaction and library retrieval."""
from __future__ import annotations
import asyncio
import json as _json
import sqlite3
from typing import Any
from engine.ai import budget as ai_budget
from engine.ai import chat as ai_chat
from engine.ai import chatlog as ai_chatlog
from engine.ai import compact as ai_compact
from engine.ai import docs as ai_docs
from engine.ai import index as ai_index
from engine.ai import note as ai_note
from engine.ai import pack as ai_pack
from engine.ai import safety as ai_safety
from engine.ai import tools as ai_tools
from engine.ai import vocab as ai_vocab
from wp_api import (
_ai_chat,
_ai_client,
_ai_embed,
_ai_search,
_ai_vocab,
_ai_web,
)
def _results_tail(question: str, *, last: bool, native: bool, split: bool = False) -> str:
"""What follows a step's results. The user's question goes with them:
once a step's results are the newest message, small models forget what
was asked and answer with a greeting. `split`: the chat asks each step
in two parts (`_ai_chat.split_step`), so being ready means calling
`answer` (native) or no calls (JSON); the message comes after."""
asked = f"\n\nThe user's message you are answering: \"{ai_budget.clip(question, 600)}\"" \
if question else ""
if split:
done = ("call answer, then write your reply" if native
else "answer with \"calls\": [], then write your reply")
else:
done = ("answer in plain text without calling a tool" if native
else "answer with \"calls\": [] and a message")
if last:
tail = ("\n\nNo more tool calls. Answer the user's message now from these results"
+ (", in plain text, with" if split else f": {done} that gives")
+ " the actual answer.")
else:
tail = (f"\n\nIf these results answer the user's message, {done} that gives the "
"actual answer. Otherwise make the next calls. Never repeat a call you already "
"made.")
return asked + tail
def _decls(cfg: dict[str, Any], meta: dict[str, Any], fmt: str) -> list[dict[str, Any]]:
"""The chat's native tool declarations, in its own order (later
additions at the end), with the `answer` tool when its steps split."""
if fmt != "native":
return []
out = ai_tools.native(ai_tools.tools(meta["tools"]), cfg["api"])
if _splits(cfg, meta, fmt):
out.append(ai_tools.answer_decl(cfg["api"]))
return out
def _splits(cfg: dict[str, Any], meta: dict[str, Any], fmt: str) -> bool:
"""Whether this chat asks each step in two parts (`_ai_chat.split_step`):
set when the chat starts (small and medium models), JSON mode or a
native server that takes tool_choice."""
return bool(meta.get("split")) and (fmt == "json" or _ai_chat.split_native(cfg))
async def _chat_step(
cfg: dict[str, Any], *, system: str, log: list[dict[str, Any]], fmt: str,
names: list[str], prof: dict[str, Any], num_ctx: int, cache_key: str,
emit: Any = None, typed: bool = False, decls: list[dict[str, Any]] | None = None,
split: bool = False, answer_only: bool = False,
offer: list[Any] | None = None, must_call: bool = False,
plain: list[Any] | None = None,
) -> dict[str, Any]:
"""One model step, asked again once with the problem named when a
JSON-mode answer isn't usable: small models usually fix a named error.
`typed`: JSON-mode args follow each tool's schema (local servers); a
server that can't take that schema is asked again with the string form.
`split`: asked in two parts, act then answer (`_ai_chat.split_step`);
`answer_only` skips the act part (the step is told to answer).
`offer` (typed JSON only): the tools this step's grammar allows instead
of all of the chat's (a shortlist, a plan, a plan item); the prompt and
native declarations stay the same, so the cached prefix does too.
`must_call`: a split act ask that must call (with `offer`). `plain`:
the offer before `engine/ai/narrow.py` limited its fields, asked
instead if the server's grammar refuses the narrowed one."""
chosen = ai_tools.tools(names)
native = decls if decls is not None else ai_tools.native(chosen, cfg["api"])
typed = typed and fmt == "json" and _ai_chat.checks_schema(cfg)
if typed and offer:
chosen = list(offer)
else:
must_call = False
schema = ai_tools.step_schema([t.name for t in chosen], chosen if typed else None)
problem = ""
attempt = 0
while attempt < 2:
asked = log if not problem else [*log, {"role": "user", "content": (
f"Your last answer couldn't be used ({problem}). Answer again with only the "
"JSON object.")}]
try:
if split:
return await _ai_chat.split_step(
cfg, system=system, log=asked, fmt=fmt, offered=chosen, tools=native,
typed=typed, act_temp=prof.get("act_temp", prof["chat_temp"]),
write_temp=prof.get("write_temp", prof["chat_temp"]),
max_calls=int(prof.get("act_calls") or 0), num_ctx=num_ctx,
cache_key=cache_key, emit=emit, answer_only=answer_only,
must_call=must_call,
)
return await _ai_chat.step(
cfg, system=system, log=asked, fmt=fmt, tools=native,
names=[t.name for t in chosen],
schema=schema, temperature=prof["chat_temp"], num_ctx=num_ctx,
cache_key=cache_key, emit=emit,
)
except _ai_chat.ToolsUnsupported:
raise
except _ai_client.AIError as e:
if typed and plain is not None and _ai_chat.refused_enums(cfg, e):
chosen, plain = list(plain), None
schema = ai_tools.step_schema([t.name for t in chosen], chosen)
continue # the same ask, without the enums
if typed and _ai_chat.refused_schema(cfg, e):
chosen, must_call = ai_tools.tools(names), False
typed, schema = False, ai_tools.step_schema(names)
continue # the same ask, args as a string
if attempt or fmt != "json" or "JSON" not in e.message:
raise
problem = "it wasn't valid JSON"
except ValueError as e:
if attempt:
raise _ai_client.AIError(
f"the model's answer couldn't be used ({e}). Try again, or a larger "
"model.") from e
problem = str(e)
attempt += 1
raise _ai_client.AIError("the model's answer couldn't be used")
def _final_step(fmt: str, message: str) -> dict[str, Any]:
"""The reply the user saw, as a step that calls nothing, when the model's
last step called tools: the turn must end on an answer so the next one
reads like a conversation (and strict chat templates accept it)."""
answer = {"calls": [], "message": message, "plan": ""} if fmt == "json" else None
return ai_chatlog.step_entry(ai_chatlog.dumps(answer) if answer else message, [],
answer=answer, synthetic=True)
#: Told to the model when the user changes a permission mid-chat.
_PERM_NOTES = {
("delete", True): "(The user turned on deleting: delete_items is available now. Use it "
"only for what they ask to delete.)",
("delete", False): "(The user turned off deleting: delete_items is gone. If they ask to "
"delete something, tell them it is off in Settings › AI assistant.)",
("auto", True): "(The user turned on auto-apply: description, tag and category changes "
"from update_info, and new categories, are applied as soon as you "
"propose them; the user can undo them. Other changes stay cards.)",
("auto", False): "(Auto-apply is off: every change is a card the user applies.)",
("web", True): "(The user set up web search: web_search is available now. Use it on "
"your own whenever references would help; the user allows each query.)",
("web", False): "(Web search is off now: web_search is gone.)",
("reader", True): "(read_result is available now: when a search snippet isn't enough, "
"read one of its result pages; the user allows each read.)",
("reader", False): "(Reading result pages is off now: read_result is gone.)",
("danbooru", True): "(The user turned on the Danbooru lookup: danbooru looks up a tag's "
"wiki, aliases, implications and related tags; the user allows each.)",
("danbooru", False): "(The Danbooru lookup is off now: danbooru is gone.)",
}
#: The same, for a lazy chat whose web or Danbooru tools aren't loaded yet.
_LAZY_NOTES = {
"web": "(The user set up web search: call more_tools with {\"group\": \"web\"} to get "
"web_search when references would help; the user allows each query.)",
"reader": "(Reading result pages is on too: more_tools {\"group\": \"web\"} also gives "
"read_result.)",
"danbooru": "(The user turned on the Danbooru lookup: call more_tools with {\"group\": "
"\"danbooru\"} to look up a tag's wiki, aliases and related tags.)",
}
async def _vocab_kinds(body: dict[str, Any]) -> set[str]:
"""The lookup sources this request may use (`_ai_vocab.available`);
the first call may load the tag list, so it runs off the event loop."""
if not ai_vocab.flags(body.get("vocab")):
return set()
loop = asyncio.get_running_loop()
try:
return await loop.run_in_executor(None, _ai_vocab.available, body.get("vocab"))
except Exception: # noqa: BLE001 - a broken tag file only turns the lookup off
return set()
def _sync_vocab(meta: dict[str, Any], kinds: set[str]) -> str:
"""Offer `lookup` while a source is on, and say which (once, in the
tail, when it changes)."""
now = sorted(kinds)
if list(meta.get("vocab") or []) == now:
return ""
meta["vocab"] = now
meta["tools"] = ai_tools.sync_delete(list(meta.get("tools") or []), bool(now),
ai_tools.VOCAB)
if not now:
return "(The user's tag, LoRA and embedding lookups are off now: lookup is gone.)"
names = ", ".join(ai_vocab.LABELS[k] for k in now)
return (f"(lookup can search the user's {names} now: check tag-style options with it "
"and name the user's real LoRAs.)")
def _canvas_note(meta: dict[str, Any], body: dict[str, Any]) -> str:
"""What the user's open canvas sets at run time, when it changed."""
canvas = ai_pack.canvas_vars(body.get("canvas"))
if not isinstance(body.get("canvas"), list):
return ""
names = [f"{c['source']}:{c['name']}" for c in canvas]
if names == list(meta.get("canvas") or []):
return ""
meta["canvas"] = names
return ai_pack.canvas_note(canvas) or "(No WP Context Injector, Loop or Model Info " \
"variables on the user's open canvas now.)"
def _sync_permissions(meta: dict[str, Any], cfg: dict[str, Any]) -> str:
"""Bring the chat's tool list in line with Settings › AI assistant ›
Permissions, and say what changed (once, in the message tail, so the
cached prefix only moves when the user flips a switch). No tool can
change these settings."""
notes = []
for key, flag in (("delete", "allow_delete"), ("auto", "auto_apply")):
now = bool(cfg.get(flag))
if bool(meta.get(key)) != now:
meta[key] = now
notes.append(_PERM_NOTES[(key, now)])
web = _ai_search.enabled(cfg)
flags = {"web": web, "reader": web and _ai_web.reader_ready(cfg),
"danbooru": bool(cfg.get("danbooru_lookup"))}
# A lazy chat (small and medium models) loads web and Danbooru tools
# with more_tools: switching one on only says so, until it is loaded.
lazy = bool(meta.get("lazy"))
loaded = set(meta.get("groups") or [])
for key, now in flags.items():
if bool(meta.get(key)) != now:
meta[key] = now
group = "danbooru" if key == "danbooru" else "web"
notes.append(_LAZY_NOTES[key] if now and lazy and group not in loaded
else _PERM_NOTES[(key, now)])
tools = ai_tools.sync_delete(list(meta.get("tools") or []), bool(meta.get("delete")))
web_in = not lazy or "web" in loaded
tools = ai_tools.sync_delete(tools, web and web_in, ai_tools.WEB_SEARCH)
tools = ai_tools.sync_delete(tools, flags["reader"] and web_in, ai_tools.READER)
meta["tools"] = ai_tools.sync_delete(
tools, flags["danbooru"] and (not lazy or "danbooru" in loaded), ai_tools.DANBOORU)
return " ".join(notes)
def _more_tools(meta: dict[str, Any], group: Any = None) -> str:
"""Add one group of tools (or all the rest) to the chat, at the end of
its list so what was already declared keeps its place (`ai_tools.more`).
Web and Danbooru tools come only while the user has them switched on."""
def allowed(t: Any) -> bool:
return ((t.kind != "web" or ai_tools.web_allowed(
t.name, web=bool(meta.get("web")), reader=bool(meta.get("reader")),
danbooru=bool(meta.get("danbooru"))))
and (t.name not in ai_tools.DELETE or bool(meta.get("delete")))
and (t.name not in ai_tools.VOCAB or bool(meta.get("vocab"))))
name = group if isinstance(group, str) and group else None
tools, added = ai_tools.more(list(meta.get("tools") or []), name, allowed)
# Loaded lazy groups stay loaded (`_sync_permissions`), also when they
# came with "every tool".
lazy = {g for t in added for g in t.groups if g in ai_tools.LAZY_GROUPS}
if name or lazy:
meta["groups"] = sorted({*(meta.get("groups") or []), *lazy, *([name] if name else [])})
if not added:
if name == "web" and not meta.get("web"):
return ("web search isn't set up; the user turns it on in Settings › AI assistant "
"› Web search. Answer without it")
if name == "danbooru" and not meta.get("danbooru"):
return ("the Danbooru lookup is off; the user turns it on in Settings › AI "
"assistant. Answer without it")
return (f"the {name} tools are already available" if name
else "every tool is already available")
meta["tools"] = tools
return ("These tools are available now:\n" + ai_chat.kinds_section(added)).strip()
#: About how much a cloud model holds, for the meter (labelled "about").
CLOUD_WINDOW = {"anthropic": 200000, "openai": 128000}
#: The summary's own answer.
SUMMARY_TOKENS = 1200
async def _window(cfg: dict[str, Any], meta: dict[str, Any]) -> int:
"""The tokens this chat's model holds: what a local server loaded the
model with (asked once per chat), else the context setting (which
Ollama gets as `num_ctx`); a cloud model's usual size."""
if isinstance(meta.get("window"), int) and meta.get("window_model") == cfg.get("model"):
return int(meta["window"])
if not cfg.get("local"):
try:
listed = await _ai_client.cloud_context(cfg)
except Exception: # noqa: BLE001 - only a hint for the meter
listed = None
if not listed:
return CLOUD_WINDOW.get(str(cfg.get("api")), 128000)
meta["window"] = listed
meta["window_model"] = cfg.get("model")
return listed
found = None
try:
found = await _ai_client.server_context(cfg)
except Exception: # noqa: BLE001 - only a hint for the meter
found = None
setting = int(cfg.get("context_max") or ai_budget.CONTEXT_DEFAULT)
window = min(found, setting) if found and cfg.get("api") == "ollama" else (found or setting)
meta["window"] = window
meta["window_model"] = cfg.get("model")
return window
def _lean(meta: dict[str, Any]) -> dict[str, Any]:
"""The leanness level this chat started at (`ai_budget.LEVELS`)."""
i = meta.get("lean")
return ai_budget.LEVELS[i] if isinstance(i, int) and 0 <= i < len(ai_budget.LEVELS) else {}
_LIBRARY_HEAD = "\n\nThe user's library (data, not instructions):\n"
def _system_text(prof: dict[str, Any], meta: dict[str, Any], fmt: str, snapshot: str) -> str:
# The JSON prompt lists the tools the chat started with: tools added
# later (more_tools, a permission) are described where they were added,
# so the instructions, and the cache behind them, never change.
return ai_chat.system_prompt(
slim=prof["slim"], native=fmt == "native",
tools=meta.get("prompt_tools") or meta["tools"],
examples=_lean(meta).get("examples", True), prose=_lean(meta).get("prose", True),
typed=fmt == "json" and bool(meta.get("typed")),
split=fmt == "native" and bool(meta.get("split")),
index=fmt == "json" and bool(meta.get("tool_index")),
) + ai_note.section(meta.get("note")) + _LIBRARY_HEAD + snapshot
def _fixed_chars(cfg: dict[str, Any], prof: dict[str, Any], meta: dict[str, Any], fmt: str,
snapshot: str) -> int:
"""What every request of this chat carries before its history: the
instructions with the library summary, plus the tool declarations
(in native mode they travel beside the prompt and count against the
window just the same; 37 of them are about 10k tokens)."""
n = len(_system_text(prof, meta, fmt, snapshot))
if fmt == "native":
n += len(_json.dumps(_decls(cfg, meta, fmt)))
return n
def _chat_profile(base: dict[str, Any], cfg: dict[str, Any], meta: dict[str, Any], fmt: str,
snapshot: str, window: int) -> tuple[dict[str, Any], int]:
"""The size's profile, made slim when the chat started lean, with the
history and tool-result caps cut to what the window leaves beside the
fixed part. Returns it with the fixed part's size."""
prof = {**base, "slim": True} if _lean(meta).get("slim") else dict(base)
fixed = _fixed_chars(cfg, prof, meta, fmt, snapshot)
room = ai_budget.room_chars(window, fixed)
prof["history_chars"] = min(prof["history_chars"], room)
prof["result_chars"] = min(prof["result_chars"], max(1500, room // 2))
return prof, fixed
def _open_chat(lib: Any, cfg: dict[str, Any], meta: dict[str, Any], base: dict[str, Any],
page: dict[str, Any] | None, fmt: str, window: int, start: int = 0) -> str:
"""Pick a new chat's tools and library summary: the leanest level from
`start` whose fixed part fits the model's window (the last one when
none does; the server's own count then decides). Sets `meta["tools"]`
and `meta["lean"]` and returns the library summary. The "About my
library" note is kept in `meta["note"]` as it was when the chat opened,
so the instructions never change under a chat."""
note = ai_note.clean(cfg.get("library_note"))
if note and "note" not in meta:
meta["note"] = note
snapshot = ""
for level in range(start, len(ai_budget.LEVELS)):
lean = ai_budget.LEVELS[level]
slim = base["slim"] or bool(lean.get("slim"))
if level:
meta["lean"] = level
else:
meta.pop("lean", None)
meta["tools"] = ai_tools.toolset(page.get("route") if page else None,
scoped=slim or bool(base.get("scoped")),
wide=not slim,
web=bool(meta.get("web")),
delete=bool(meta.get("delete")),
vocab=bool(meta.get("vocab")),
reader=bool(meta.get("reader")),
danbooru=bool(meta.get("danbooru")))
if meta.get("lazy"):
meta["prompt_tools"] = list(meta["tools"])
limit = lean.get("overview") or (40 if slim else None)
snapshot = ai_safety.strip_invisible(lib.overview(limit=limit))
prof = {**base, "slim": slim}
if ai_budget.fits(_fixed_chars(cfg, prof, meta, fmt, snapshot), window):
break
return snapshot
def _too_small(window: int, fixed: int, counted: tuple[int, int] | None) -> str:
"""Why a chat can't run in this model's window, and what to change."""
prompt, n_ctx = counted if counted and counted[1] else (ai_budget.tokens(fixed), window)
want = ai_budget.suggest_window(int(prompt * ai_budget.CHARS_PER_TOKEN))
return (f"the model was loaded with {n_ctx:,} tokens of context, and this request needs "
f"about {prompt:,} (the assistant's instructions, tools and your library summary "
f"take most of it). Load the model with at least {want:,} tokens of context: "
"LM Studio and llama.cpp set it when the model loads, Ollama takes Settings › "
"AI assistant › Most context to use. Or set Model size to Small, which sends "
"fewer tools")
def _context(cfg: dict[str, Any], meta: dict[str, Any], entries: list[dict[str, Any]],
system_chars: int, usage: dict[str, Any], window: int) -> dict[str, Any]:
"""The meter: tokens the last request used (the provider's count when
it gave one, else an estimate) against the window."""
rendered = ai_chatlog.size(ai_chatlog.render(entries, meta)) + system_chars
estimate = ai_budget.tokens(rendered)
output = int(usage.get("output") or 0)
if usage.get("prompt"):
used, estimated = int(usage["prompt"]) + output, False
else:
# Ollama counts only the prompt tokens it had to read again (none
# of a cached prefix), so its number drops to a few hundred after a
# cache hit: estimate the whole prompt instead.
used, estimated = max(estimate, int(usage.get("evaluated") or 0)) + output, True
return {"used": used, "window": window, "estimated": estimated,
"about": not cfg.get("local"), "compactions": int(meta.get("compactions") or 0),
# The divider in the chat goes before this user turn.
"summarised_turns": ai_compact.summarised_turns(entries, meta),
"compact_failed": bool(meta.get("compact_failed"))}
async def _maybe_compact(cfg: dict[str, Any], log: list[dict[str, Any]], meta: dict[str, Any],
prof: dict[str, Any], num_ctx: int, window: int, fixed_chars: int,
emit: Any, *, force: bool = False) -> dict[str, Any]:
"""Summarise the turns before the last few when the prompt nears the
window (or when asked). A failed summary changes nothing: the older
masking and dropping still keep the prompt in budget."""
if not force:
need = ai_budget.tokens(ai_chatlog.size(ai_chatlog.render(log, meta)) + fixed_chars)
if need < ai_compact.threshold(window, prof.get("compact_at")):
return meta
cut = ai_compact.best_split(log, meta)
if cut is None:
return meta
if not force and cut <= int(meta.get("compact_failed") or 0):
return meta # failed on these turns already; try again once there's more
if emit:
await emit({"type": "status", "v": "Summarizing earlier messages to make room"})
summary = None
# Small models miss the summary's shape now and then: once more with
# less to read before giving up.
for limit in (ai_compact.MAX_TRANSCRIPT, ai_compact.MAX_TRANSCRIPT // 3):
try:
answer = await _ai_client.complete_json(
cfg, system=ai_compact.SYSTEM, user=ai_compact.prompt(log, meta, cut, limit),
schema=ai_compact.SCHEMA, schema_name="summary", temperature=0.2,
max_tokens=SUMMARY_TOKENS,
num_ctx=num_ctx if cfg.get("api") == "ollama" else None)
except _ai_client.AIError:
continue
got = answer.get("summary") if isinstance(answer, dict) else None
if isinstance(got, str) and len(got.strip()) >= 20:
summary = got
break
if summary is None:
# Said, not silent: the meter shows it, and older results are still
# trimmed to keep the prompt in budget.
if emit:
await emit({"type": "status",
"v": "Couldn't summarize; trimming older results instead"})
return {**meta, "compact_failed": cut}
text = ai_safety.strip_invisible(ai_compact.finish(
summary, ai_compact.ids_in(log, meta, cut), meta.get("decisions")))
return ai_compact.apply(meta, text, cut)
#: One library index per library database file (see `engine.ai.index`).
_INDEXES: dict[str, ai_index.Index | None] = {}
#: Ids and docs sections a chat remembers having handed the model.
MAX_SHOWN = 400
def _synced_index(conn: Any, lib: ai_chat.Library) -> ai_index.Index | None:
"""The library index, brought up to date with the saved rows; None when
SQLite has no FTS5 or the index can't be used (search then scores rows
in Python, as before)."""
idx = _library_index_for(conn)
if idx is None:
return None
key = str(ai_index.index_path(ai_index.library_path(conn)) or "")
rows = [*lib.modules.values(), *lib.bundles.values(), *lib.templates.values()]
try:
idx.sync(rows, lib._kind)
except sqlite3.Error:
_INDEXES.pop(key, None)
return None
return idx
async def _query_vector(lib: ai_chat.Library, cfg: dict[str, Any],
text: str) -> list[float] | None:
"""The query's vector when meaning search is on and some items have
vectors already; else None (keywords only)."""
if lib.index is None or not lib.embed_model:
return None
try:
if lib.index.vector_count(lib.embed_model) == 0:
return None
except sqlite3.Error:
return None
return await _ai_embed.query_vector(cfg, text)
def _library_index_for(conn: Any) -> ai_index.Index | None:
path = ai_index.index_path(ai_index.library_path(conn))
key = str(path or "")
if key not in _INDEXES:
_INDEXES[key] = ai_index.open_index(path)
return _INDEXES[key]
async def _retrieval(lib: ai_chat.Library, text: str, meta: dict[str, Any],
prof: dict[str, Any], cfg: dict[str, Any]) -> str:
"""What the library index and the docs say this message is about, for
the end of the message (never the system prompt: the request prefix
stays the same). Gated; items and sections this chat already got are
left out. Large models get names only: they search well, and every
token here is paid on every later request of a cloud chat."""
shown = set(meta.get("shown") or [])
parts: list[str] = []
new: list[str] = []
if lib.index is not None:
try:
mblock, mids = ai_index.mentioned(lib.index, text, full=prof["size"] != "large")
except sqlite3.Error:
mblock, mids = "", []
if mblock:
parts.append(mblock)
new.extend(m for m in mids if m not in shown)
shown |= set(mids)
skip = shown | ({lib.editor_id} if lib.editor_id else set())
qvec = await _query_vector(lib, cfg, text)
try:
block, ids = ai_index.retrieve(lib.index, text, seen=skip,
limit=4 if prof["slim"] else 6,
full=prof["size"] != "large",
qvec=qvec, model=lib.embed_model)
except sqlite3.Error:
block, ids = "", []
if block:
parts.append(block)
new.extend(ids)
dblock, keys = ai_docs.retrieve(text, seen={k[4:] for k in shown if k.startswith("doc:")})
if dblock:
parts.append("Docs sections that may answer this (docs_read them before answering):\n"
+ dblock)
new.extend("doc:" + k for k in keys)
if new:
meta["shown"] = [*(meta.get("shown") or []), *new][-MAX_SHOWN:]
return "\n\n".join(parts)