virtus 0.0.3__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- virtus/__init__.py +109 -0
- virtus/_version.py +24 -0
- virtus/agent.py +177 -0
- virtus/capability.py +126 -0
- virtus/compaction.py +75 -0
- virtus/dispatch.py +144 -0
- virtus/journal.py +244 -0
- virtus/knowledge.py +401 -0
- virtus/lazy.py +166 -0
- virtus/live.py +201 -0
- virtus/message.py +201 -0
- virtus/middleware.py +14 -0
- virtus/observe.py +186 -0
- virtus/output.py +337 -0
- virtus/pdfs.py +51 -0
- virtus/provider.py +61 -0
- virtus/providers/__init__.py +20 -0
- virtus/providers/anthropic.py +200 -0
- virtus/providers/chat_completions.py +287 -0
- virtus/providers/embeddings.py +55 -0
- virtus/providers/factories.py +41 -0
- virtus/providers/openai_realtime.py +148 -0
- virtus/providers/responses.py +220 -0
- virtus/request.py +48 -0
- virtus/servers.py +187 -0
- virtus/thread.py +236 -0
- virtus/tool.py +91 -0
- virtus/tools/__init__.py +24 -0
- virtus/tools/calculator.py +107 -0
- virtus/tools/http.py +86 -0
- virtus/tools/net.py +61 -0
- virtus/tools/papers.py +102 -0
- virtus/tools/pdf.py +41 -0
- virtus/tools/websearch.py +122 -0
- virtus/tools/wikipedia.py +53 -0
- virtus/vault.py +77 -0
- virtus/workspace.py +92 -0
- virtus-0.0.3.dist-info/METADATA +547 -0
- virtus-0.0.3.dist-info/RECORD +41 -0
- virtus-0.0.3.dist-info/WHEEL +4 -0
- virtus-0.0.3.dist-info/licenses/LICENSE.md +21 -0
virtus/journal.py
ADDED
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
"""Where threads keep their history: the only state a conversation has, so it outlives the process and resumes.
|
|
2
|
+
|
|
3
|
+
A thread holds no messages of its own; it reads its history from the journal before each run and writes
|
|
4
|
+
it back after every completed step. `MemoryJournal` is the default and lives as long as the agent;
|
|
5
|
+
`JsonJournal` puts the same thing on disk. The history is all the framework needs to continue: tool calls
|
|
6
|
+
never sit without their results in it, and rewrites made by middleware are already applied. State that
|
|
7
|
+
lives elsewhere (a `Capabilities` instance, a kernel) is not covered; what needs to survive goes to disk
|
|
8
|
+
by its owner, or is rebuilt from the history.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import shutil
|
|
12
|
+
from base64 import b64decode, b64encode
|
|
13
|
+
from collections.abc import Sequence
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import Annotated, Literal, Protocol, runtime_checkable
|
|
16
|
+
from urllib.parse import quote, unquote
|
|
17
|
+
|
|
18
|
+
from pydantic import BaseModel, Field, TypeAdapter
|
|
19
|
+
|
|
20
|
+
from virtus.message import (
|
|
21
|
+
AssistantMessage,
|
|
22
|
+
Audio,
|
|
23
|
+
AudioType,
|
|
24
|
+
File,
|
|
25
|
+
FileType,
|
|
26
|
+
Image,
|
|
27
|
+
ImageType,
|
|
28
|
+
Message,
|
|
29
|
+
ToolCall,
|
|
30
|
+
ToolResult,
|
|
31
|
+
Usage,
|
|
32
|
+
UserMessage,
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@runtime_checkable
|
|
37
|
+
class Journal(Protocol):
|
|
38
|
+
"""`path` is `(agent name, id)` for a root thread; a thread opened by delegation appends the agent name
|
|
39
|
+
and the conversation name to its parent's path. `delete` removes the thread and every thread under it;
|
|
40
|
+
`children` lists the (agent name, conversation name) pairs directly under a thread.
|
|
41
|
+
"""
|
|
42
|
+
|
|
43
|
+
def read(self, path: Sequence[str]) -> tuple[Message, ...] | None: ...
|
|
44
|
+
|
|
45
|
+
def write(self, path: Sequence[str], history: Sequence[Message]) -> None: ...
|
|
46
|
+
|
|
47
|
+
def delete(self, path: Sequence[str]) -> None: ...
|
|
48
|
+
|
|
49
|
+
def children(self, path: Sequence[str]) -> Sequence[tuple[str, str]]: ...
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class MemoryJournal(Journal):
|
|
53
|
+
"""Threads kept in a dict, for as long as the journal is referenced."""
|
|
54
|
+
|
|
55
|
+
def __init__(self) -> None:
|
|
56
|
+
self._threads: dict[tuple[str, ...], tuple[Message, ...]] = {}
|
|
57
|
+
|
|
58
|
+
def read(self, path: Sequence[str]) -> tuple[Message, ...] | None:
|
|
59
|
+
return self._threads.get(tuple(path))
|
|
60
|
+
|
|
61
|
+
def write(self, path: Sequence[str], history: Sequence[Message]) -> None:
|
|
62
|
+
self._threads[tuple(path)] = tuple(history)
|
|
63
|
+
|
|
64
|
+
def delete(self, path: Sequence[str]) -> None:
|
|
65
|
+
prefix = tuple(path)
|
|
66
|
+
for key in [k for k in self._threads if k[: len(prefix)] == prefix]:
|
|
67
|
+
del self._threads[key]
|
|
68
|
+
|
|
69
|
+
def children(self, path: Sequence[str]) -> tuple[tuple[str, str], ...]:
|
|
70
|
+
prefix = tuple(path)
|
|
71
|
+
return tuple((k[-2], k[-1]) for k in self._threads if len(k) == len(prefix) + 2 and k[: len(prefix)] == prefix)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
class JsonJournal(Journal):
|
|
75
|
+
"""One JSON file per thread, `directory/<agent>/<id>.json`; children live in a folder named after their parent."""
|
|
76
|
+
|
|
77
|
+
def __init__(self, directory: str | Path) -> None:
|
|
78
|
+
self.directory = Path(directory)
|
|
79
|
+
|
|
80
|
+
def read(self, path: Sequence[str]) -> tuple[Message, ...] | None:
|
|
81
|
+
file = self._file(path)
|
|
82
|
+
if not file.exists():
|
|
83
|
+
return None
|
|
84
|
+
return tuple(map(_message, _RECORD.validate_json(file.read_bytes()).messages))
|
|
85
|
+
|
|
86
|
+
def write(self, path: Sequence[str], history: Sequence[Message]) -> None:
|
|
87
|
+
file = self._file(path)
|
|
88
|
+
file.parent.mkdir(parents=True, exist_ok=True)
|
|
89
|
+
record = _Record(messages=[_entry(m) for m in history])
|
|
90
|
+
temporary = file.with_suffix(".json.tmp")
|
|
91
|
+
temporary.write_bytes(record.model_dump_json(indent=1).encode())
|
|
92
|
+
temporary.replace(file)
|
|
93
|
+
|
|
94
|
+
def delete(self, path: Sequence[str]) -> None:
|
|
95
|
+
self._file(path).unlink(missing_ok=True)
|
|
96
|
+
shutil.rmtree(self._file(path).with_suffix(""), ignore_errors=True)
|
|
97
|
+
|
|
98
|
+
def children(self, path: Sequence[str]) -> tuple[tuple[str, str], ...]:
|
|
99
|
+
folder = self._file(path).with_suffix("")
|
|
100
|
+
if not folder.is_dir():
|
|
101
|
+
return ()
|
|
102
|
+
return tuple(
|
|
103
|
+
(unquote(agent.name), unquote(file.stem))
|
|
104
|
+
for agent in sorted(folder.iterdir())
|
|
105
|
+
if agent.is_dir()
|
|
106
|
+
for file in sorted(agent.glob("*.json"))
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
def _file(self, path: Sequence[str]) -> Path:
|
|
110
|
+
"""Each segment becomes one file-system name; percent-encoding keeps `/` and `..` out of it."""
|
|
111
|
+
segments = [quote(segment, safe="").replace(".", "%2E") for segment in path]
|
|
112
|
+
return self.directory.joinpath(*segments).with_suffix(".json")
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
class _Image(BaseModel):
|
|
116
|
+
media_type: ImageType
|
|
117
|
+
data: str
|
|
118
|
+
|
|
119
|
+
@classmethod
|
|
120
|
+
def of(cls, image: Image) -> "_Image":
|
|
121
|
+
return cls(media_type=image.media_type, data=b64encode(image.data).decode())
|
|
122
|
+
|
|
123
|
+
def image(self) -> Image:
|
|
124
|
+
return Image(b64decode(self.data), self.media_type)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
class _Audio(BaseModel):
|
|
128
|
+
media_type: AudioType
|
|
129
|
+
data: str
|
|
130
|
+
|
|
131
|
+
@classmethod
|
|
132
|
+
def of(cls, audio: Audio) -> "_Audio":
|
|
133
|
+
return cls(media_type=audio.media_type, data=b64encode(audio.data).decode())
|
|
134
|
+
|
|
135
|
+
def audio(self) -> Audio:
|
|
136
|
+
return Audio(b64decode(self.data), self.media_type)
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
class _File(BaseModel):
|
|
140
|
+
media_type: FileType
|
|
141
|
+
name: str
|
|
142
|
+
data: str
|
|
143
|
+
text: str | None = None
|
|
144
|
+
|
|
145
|
+
@classmethod
|
|
146
|
+
def of(cls, file: File) -> "_File":
|
|
147
|
+
return cls(media_type=file.media_type, name=file.name, data=b64encode(file.data).decode(), text=file.text)
|
|
148
|
+
|
|
149
|
+
def file(self) -> File:
|
|
150
|
+
return File(b64decode(self.data), self.media_type, self.name, self.text)
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
class _User(BaseModel):
|
|
154
|
+
role: Literal["user"] = "user"
|
|
155
|
+
content: str
|
|
156
|
+
images: list[_Image] = []
|
|
157
|
+
audio: list[_Audio] = []
|
|
158
|
+
files: list[_File] = []
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
class _Call(BaseModel):
|
|
162
|
+
id: str
|
|
163
|
+
name: str
|
|
164
|
+
arguments: str
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
class _Usage(BaseModel):
|
|
168
|
+
input: int
|
|
169
|
+
output: int
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
class _Assistant(BaseModel):
|
|
173
|
+
role: Literal["assistant"] = "assistant"
|
|
174
|
+
content: str
|
|
175
|
+
tool_calls: list[_Call] = []
|
|
176
|
+
images: list[_Image] = []
|
|
177
|
+
audio: _Audio | None = None
|
|
178
|
+
usage: _Usage | None = None
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
class _Tool(BaseModel):
|
|
182
|
+
role: Literal["tool"] = "tool"
|
|
183
|
+
call_id: str
|
|
184
|
+
name: str
|
|
185
|
+
content: str
|
|
186
|
+
is_error: bool = False
|
|
187
|
+
images: list[_Image] = []
|
|
188
|
+
audio: list[_Audio] = []
|
|
189
|
+
files: list[_File] = []
|
|
190
|
+
tools: list[str] = []
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
type _Entry = Annotated[_User | _Assistant | _Tool, Field(discriminator="role")]
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
class _Record(BaseModel):
|
|
197
|
+
version: Literal[1] = 1
|
|
198
|
+
messages: list[_Entry]
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
_RECORD = TypeAdapter(_Record)
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def _entry(message: Message) -> _User | _Assistant | _Tool:
|
|
205
|
+
match message:
|
|
206
|
+
case UserMessage(content=content, images=images, audio=audio, files=files):
|
|
207
|
+
return _User(content=content, images=_images(images), audio=_clips(audio), files=_files(files))
|
|
208
|
+
case AssistantMessage(content=content, tool_calls=calls, images=images, audio=audio, usage=usage):
|
|
209
|
+
tool_calls = [_Call(id=c.id, name=c.name, arguments=c.arguments) for c in calls]
|
|
210
|
+
spoken = _Audio.of(audio) if audio else None
|
|
211
|
+
used = _Usage(input=usage.input, output=usage.output) if usage else None
|
|
212
|
+
return _Assistant(content=content, tool_calls=tool_calls, images=_images(images), audio=spoken, usage=used)
|
|
213
|
+
case ToolResult(call_id=call_id, name=name, content=content, is_error=is_error, images=images, audio=audio):
|
|
214
|
+
entry = _Tool(call_id=call_id, name=name, content=content, is_error=is_error, tools=list(message.tools))
|
|
215
|
+
attached = {"images": _images(images), "audio": _clips(audio), "files": _files(message.files)}
|
|
216
|
+
return entry.model_copy(update=attached)
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def _message(entry: _User | _Assistant | _Tool) -> Message:
|
|
220
|
+
match entry:
|
|
221
|
+
case _User(content=content, images=images, audio=audio, files=files):
|
|
222
|
+
media = tuple(i.image() for i in images), tuple(a.audio() for a in audio), tuple(f.file() for f in files)
|
|
223
|
+
return UserMessage(content, *media)
|
|
224
|
+
case _Assistant(content=content, tool_calls=calls, images=images, audio=audio, usage=usage):
|
|
225
|
+
tool_calls = tuple(ToolCall(c.id, c.name, c.arguments) for c in calls)
|
|
226
|
+
spoken = audio.audio() if audio else None
|
|
227
|
+
used = Usage(usage.input, usage.output) if usage else None
|
|
228
|
+
return AssistantMessage(content, tool_calls, tuple(i.image() for i in images), spoken, used)
|
|
229
|
+
case _Tool(call_id=call_id, name=name, content=content, is_error=is_error, images=images, audio=audio):
|
|
230
|
+
files = tuple(f.file() for f in entry.files)
|
|
231
|
+
media = tuple(i.image() for i in images), tuple(a.audio() for a in audio), files
|
|
232
|
+
return ToolResult(call_id, name, content, is_error, *media, tuple(entry.tools))
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def _files(files: Sequence[File]) -> list[_File]:
|
|
236
|
+
return [_File.of(f) for f in files]
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def _images(images: Sequence[Image]) -> list[_Image]:
|
|
240
|
+
return [_Image.of(i) for i in images]
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def _clips(audio: Sequence[Audio]) -> list[_Audio]:
|
|
244
|
+
return [_Audio.of(a) for a in audio]
|
virtus/knowledge.py
ADDED
|
@@ -0,0 +1,401 @@
|
|
|
1
|
+
"""Documents on disk the agent can consult, retrieved in the cheapest way their volume allows.
|
|
2
|
+
|
|
3
|
+
While everything fits the token budget, it all goes into the system prompt. Past that, only an
|
|
4
|
+
index of ids and titles goes in and the agent reads entries on demand. Past what the index
|
|
5
|
+
allows, the agent searches first and then reads: BM25 with no dependencies, or by meaning when
|
|
6
|
+
an `Embedder` is given. Search and reading go by passages, so a long document (a PDF, say) is
|
|
7
|
+
found by the part that matters and read a part at a time.
|
|
8
|
+
|
|
9
|
+
`knowledge(...)` is read-only material the developer provides; `memory(...)` is the same store
|
|
10
|
+
with tools for the agent to write and delete entries, so it persists across conversations.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
import hashlib
|
|
14
|
+
import json
|
|
15
|
+
import math
|
|
16
|
+
import re
|
|
17
|
+
from collections import Counter
|
|
18
|
+
from collections.abc import AsyncIterator, Iterator, Sequence
|
|
19
|
+
from dataclasses import dataclass, replace
|
|
20
|
+
from datetime import UTC, datetime
|
|
21
|
+
from functools import lru_cache
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
from typing import TypedDict, Unpack
|
|
24
|
+
|
|
25
|
+
from pydantic import TypeAdapter
|
|
26
|
+
|
|
27
|
+
from virtus.message import Event
|
|
28
|
+
from virtus.middleware import Middleware, Next
|
|
29
|
+
from virtus.pdfs import Reader
|
|
30
|
+
from virtus.provider import Embedder
|
|
31
|
+
from virtus.request import Request
|
|
32
|
+
from virtus.tool import Tool, tool
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@dataclass(frozen=True)
|
|
36
|
+
class Document:
|
|
37
|
+
id: str
|
|
38
|
+
"""Path relative to the root it was found under, e.g. `guides/setup.md`."""
|
|
39
|
+
title: str
|
|
40
|
+
content: str
|
|
41
|
+
path: Path
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@dataclass(frozen=True)
|
|
45
|
+
class Passage:
|
|
46
|
+
"""Part of a document, at most the store's `passage` characters long and overlapping the next by `overlap`."""
|
|
47
|
+
|
|
48
|
+
id: str
|
|
49
|
+
"""The document's id, with `#n` after it when the document has more than one passage."""
|
|
50
|
+
document: Document
|
|
51
|
+
number: int
|
|
52
|
+
"""1-based, out of `total`."""
|
|
53
|
+
total: int
|
|
54
|
+
content: str
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class Store:
|
|
58
|
+
"""Markdown, text and PDF files under `roots`, read again on every access so edits show up at once.
|
|
59
|
+
|
|
60
|
+
A `.md` file may start with a frontmatter block of `key: value` lines between `---` fences;
|
|
61
|
+
its `title` names the document. Otherwise the first heading does, and failing that the file name.
|
|
62
|
+
A PDF is its pages' text (OCR where a page has none, extra `pdf`), extracted once per version of the file.
|
|
63
|
+
|
|
64
|
+
Search and reading go by passages of at most `passage` characters, cut where a paragraph or else a word
|
|
65
|
+
ends; each starts `overlap` characters before the previous one ended, so a sentence across a cut is whole
|
|
66
|
+
in one of them.
|
|
67
|
+
|
|
68
|
+
A PDF's text comes from `reader`; the last `cache` PDFs read are kept, each until its file changes.
|
|
69
|
+
|
|
70
|
+
With an `embedder`, `search` ranks by meaning. The vectors are kept in a file named `index` in each
|
|
71
|
+
root's directory, keyed by a hash of the passage, so only new or changed passages are embedded again;
|
|
72
|
+
vectors of another size than the embedder now produces are embedded again too. Without one, it ranks by
|
|
73
|
+
BM25 with `k1` and `b`.
|
|
74
|
+
"""
|
|
75
|
+
|
|
76
|
+
def __init__(
|
|
77
|
+
self,
|
|
78
|
+
*roots: Path,
|
|
79
|
+
suffixes: Sequence[str] = (".md", ".txt", ".pdf"),
|
|
80
|
+
embedder: Embedder | None = None,
|
|
81
|
+
passage: int = 2_000,
|
|
82
|
+
overlap: int = 200,
|
|
83
|
+
index: str = ".embeddings.json",
|
|
84
|
+
reader: Reader = Reader(),
|
|
85
|
+
cache: int = 256,
|
|
86
|
+
k1: float = 1.5,
|
|
87
|
+
b: float = 0.75,
|
|
88
|
+
) -> None:
|
|
89
|
+
if not 0 <= overlap < passage // 2:
|
|
90
|
+
raise ValueError(f"overlap must be at least 0 and under half the passage, got {overlap} of {passage}")
|
|
91
|
+
self.roots = roots
|
|
92
|
+
self.suffixes = tuple(suffixes)
|
|
93
|
+
self.embedder = embedder
|
|
94
|
+
self.passage = passage
|
|
95
|
+
self.overlap = overlap
|
|
96
|
+
self.index = index
|
|
97
|
+
self.k1 = k1
|
|
98
|
+
self.b = b
|
|
99
|
+
|
|
100
|
+
def extract(file: Path, modified: int, size: int) -> str:
|
|
101
|
+
"""Keyed by modification time and size too, so a changed file is extracted again."""
|
|
102
|
+
pages = reader.pages(file.read_bytes())
|
|
103
|
+
return "\n\n".join(f"[page {n}]\n{text}" for n, text in enumerate(pages, 1))
|
|
104
|
+
|
|
105
|
+
self._pdf = lru_cache(maxsize=cache)(extract)
|
|
106
|
+
|
|
107
|
+
def documents(self) -> tuple[Document, ...]:
|
|
108
|
+
def under(root: Path) -> Iterator[Document]:
|
|
109
|
+
if root.is_file():
|
|
110
|
+
yield self.load(root, root.name)
|
|
111
|
+
elif root.is_dir():
|
|
112
|
+
for file in sorted(root.rglob("*")):
|
|
113
|
+
if file.suffix in self.suffixes:
|
|
114
|
+
try:
|
|
115
|
+
yield self.load(file, str(file.relative_to(root)))
|
|
116
|
+
except (FileNotFoundError, IsADirectoryError):
|
|
117
|
+
continue # deleted, or a directory named like a file, while listing
|
|
118
|
+
|
|
119
|
+
return tuple(d for root in self.roots for d in under(root))
|
|
120
|
+
|
|
121
|
+
def passages(self) -> tuple[Passage, ...]:
|
|
122
|
+
return tuple(p for d in self.documents() for p in _passages(d, self.passage, self.overlap))
|
|
123
|
+
|
|
124
|
+
def read(self, id: str) -> Document | None:
|
|
125
|
+
return next((d for d in self.documents() if d.id == id), None)
|
|
126
|
+
|
|
127
|
+
def load(self, file: Path, id: str) -> Document:
|
|
128
|
+
"""A heading that names a markdown document is not repeated in its content; a PDF's pages are marked."""
|
|
129
|
+
if file.suffix == ".pdf":
|
|
130
|
+
stat = file.stat()
|
|
131
|
+
return Document(id, file.stem, self._pdf(file, stat.st_mtime_ns, stat.st_size), file)
|
|
132
|
+
if file.suffix != ".md":
|
|
133
|
+
return Document(id, file.stem, file.read_text().strip(), file)
|
|
134
|
+
|
|
135
|
+
frontmatter, body = _split(file.read_text())
|
|
136
|
+
if title := frontmatter.get("title"):
|
|
137
|
+
return Document(id, title, body.strip(), file)
|
|
138
|
+
if heading := re.search(r"^#\s+(.+)$", body, re.MULTILINE):
|
|
139
|
+
return Document(
|
|
140
|
+
id, heading.group(1).strip(), (body[: heading.start()] + body[heading.end() :]).strip(), file
|
|
141
|
+
)
|
|
142
|
+
return Document(id, file.stem, body.strip(), file)
|
|
143
|
+
|
|
144
|
+
async def search(self, query: str, limit: int = 5) -> list[Passage]:
|
|
145
|
+
"""The `limit` passages nearest the query: by cosine with an embedder, else by BM25 over title and text."""
|
|
146
|
+
passages = self.passages()
|
|
147
|
+
if self.embedder is None:
|
|
148
|
+
return _bm25(passages, query, limit, self.k1, self.b)
|
|
149
|
+
|
|
150
|
+
[vector] = await self.embedder.embed([query], "query")
|
|
151
|
+
vectors = [v for root in self.roots for v in await self._vectors(root, len(vector))]
|
|
152
|
+
ranked = sorted(zip(map(lambda v: _cosine(vector, v), vectors), passages, strict=True), key=lambda p: -p[0])
|
|
153
|
+
return [passage for _, passage in ranked[:limit]]
|
|
154
|
+
|
|
155
|
+
async def _vectors(self, root: Path, size: int) -> list[list[float]]:
|
|
156
|
+
"""The vectors of the passages under `root`, from its index where the text hasn't changed."""
|
|
157
|
+
assert self.embedder is not None
|
|
158
|
+
passages = [p for p in self.passages() if p.document.path == root or p.document.path.is_relative_to(root)]
|
|
159
|
+
index = (root if root.is_dir() else root.parent) / self.index
|
|
160
|
+
cached = _INDEX.validate_json(index.read_bytes()) if index.exists() else {}
|
|
161
|
+
keys = [_digest(p) for p in passages]
|
|
162
|
+
kept = {k: v for k, v in cached.items() if k in keys and len(v) == size}
|
|
163
|
+
|
|
164
|
+
missing = [(k, p) for k, p in zip(keys, passages, strict=True) if k not in kept]
|
|
165
|
+
if missing:
|
|
166
|
+
fresh = await self.embedder.embed([_text(p) for _, p in missing], "document")
|
|
167
|
+
kept |= {k: v for (k, _), v in zip(missing, fresh, strict=True)}
|
|
168
|
+
if kept != cached:
|
|
169
|
+
index.parent.mkdir(parents=True, exist_ok=True)
|
|
170
|
+
index.write_text(json.dumps(kept))
|
|
171
|
+
return [kept[k] for k in keys]
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
class Options(TypedDict, total=False):
|
|
175
|
+
"""The `Store` settings `knowledge` and `memory` pass on."""
|
|
176
|
+
|
|
177
|
+
suffixes: Sequence[str]
|
|
178
|
+
embedder: Embedder | None
|
|
179
|
+
passage: int
|
|
180
|
+
overlap: int
|
|
181
|
+
index: str
|
|
182
|
+
reader: Reader
|
|
183
|
+
cache: int
|
|
184
|
+
k1: float
|
|
185
|
+
b: float
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
KNOWLEDGE = "Reference material you can rely on."
|
|
189
|
+
MEMORY = "Notes you kept from earlier conversations. Save new ones with {name}_remember."
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def knowledge(
|
|
193
|
+
*paths: str | Path,
|
|
194
|
+
name: str = "knowledge",
|
|
195
|
+
intro: str = KNOWLEDGE,
|
|
196
|
+
max_tokens: int = 4_000,
|
|
197
|
+
chars_per_token: int = 4,
|
|
198
|
+
hits: int = 5,
|
|
199
|
+
snippet: int = 160,
|
|
200
|
+
**options: Unpack[Options],
|
|
201
|
+
) -> Middleware:
|
|
202
|
+
"""Let the agent consult `.md`, `.txt` and `.pdf` files, or whole directories of them, read-only.
|
|
203
|
+
|
|
204
|
+
`name` prefixes the tools and the section in the system prompt, so two knowledge bases can
|
|
205
|
+
coexist; `intro` opens that section. `max_tokens` (estimated as `chars_per_token` characters each)
|
|
206
|
+
is how much of the prompt the content, or failing that the index, may take before the agent has to
|
|
207
|
+
read or search instead. A search lists `hits` passages, each with its first `snippet` characters.
|
|
208
|
+
`options` go to the `Store`: an `embedder` makes the search semantic, `passage` and `overlap` size
|
|
209
|
+
the parts it goes by.
|
|
210
|
+
"""
|
|
211
|
+
return _consult(
|
|
212
|
+
Store(*map(Path, paths), **options),
|
|
213
|
+
name,
|
|
214
|
+
intro,
|
|
215
|
+
max_tokens,
|
|
216
|
+
chars_per_token,
|
|
217
|
+
hits,
|
|
218
|
+
snippet,
|
|
219
|
+
manage=(),
|
|
220
|
+
)
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def memory(
|
|
224
|
+
path: str | Path,
|
|
225
|
+
*,
|
|
226
|
+
name: str = "memory",
|
|
227
|
+
intro: str = MEMORY,
|
|
228
|
+
max_tokens: int = 4_000,
|
|
229
|
+
chars_per_token: int = 4,
|
|
230
|
+
hits: int = 5,
|
|
231
|
+
snippet: int = 160,
|
|
232
|
+
**options: Unpack[Options],
|
|
233
|
+
) -> Middleware:
|
|
234
|
+
"""Give the agent a directory of markdown notes it writes and reads back in later conversations.
|
|
235
|
+
|
|
236
|
+
Each note is a file named after its title, with the title and creation time in frontmatter.
|
|
237
|
+
Notes are plain files, so they can be read and edited by hand. `{name}` in `intro` is the name;
|
|
238
|
+
the other arguments are those of `knowledge`.
|
|
239
|
+
"""
|
|
240
|
+
root = Path(path)
|
|
241
|
+
store = Store(root, **options)
|
|
242
|
+
|
|
243
|
+
def remember(title: str, content: str) -> str:
|
|
244
|
+
"""Save something worth knowing in later conversations. The same title overwrites the note."""
|
|
245
|
+
root.mkdir(parents=True, exist_ok=True)
|
|
246
|
+
file = root / f"{_slug(title)}.md"
|
|
247
|
+
created = datetime.now(UTC).isoformat(timespec="seconds")
|
|
248
|
+
file.write_text(f"---\ntitle: {title}\ncreated: {created}\n---\n\n{content.strip()}\n")
|
|
249
|
+
return f"saved as {file.name}"
|
|
250
|
+
|
|
251
|
+
def forget(id: str) -> str:
|
|
252
|
+
"""Delete a note by id."""
|
|
253
|
+
if (document := store.read(id)) is None:
|
|
254
|
+
raise FileNotFoundError(f"no note {id!r}")
|
|
255
|
+
document.path.unlink()
|
|
256
|
+
return f"deleted {id}"
|
|
257
|
+
|
|
258
|
+
return _consult(
|
|
259
|
+
store,
|
|
260
|
+
name,
|
|
261
|
+
intro.format(name=name),
|
|
262
|
+
max_tokens,
|
|
263
|
+
chars_per_token,
|
|
264
|
+
hits,
|
|
265
|
+
snippet,
|
|
266
|
+
manage=(tool(remember, name=f"{name}_remember"), tool(forget, name=f"{name}_forget")),
|
|
267
|
+
)
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def _consult(
|
|
271
|
+
store: Store,
|
|
272
|
+
name: str,
|
|
273
|
+
intro: str,
|
|
274
|
+
max_tokens: int,
|
|
275
|
+
chars_per_token: int,
|
|
276
|
+
hits: int,
|
|
277
|
+
snippet: int,
|
|
278
|
+
*,
|
|
279
|
+
manage: tuple[Tool, ...],
|
|
280
|
+
) -> Middleware:
|
|
281
|
+
def read(id: str) -> str:
|
|
282
|
+
"""A long entry is read a passage at a time: its id alone gives the first, `id#n` the n-th."""
|
|
283
|
+
passages = {p.id: p for p in store.passages()}
|
|
284
|
+
if (passage := passages.get(id) or passages.get(f"{id}#1")) is None:
|
|
285
|
+
raise FileNotFoundError(f"no entry {id!r}")
|
|
286
|
+
text = f"# {passage.document.title}\n\n{passage.content}"
|
|
287
|
+
if passage.total == 1:
|
|
288
|
+
return text
|
|
289
|
+
following = f"; next is {passage.document.id}#{passage.number + 1}" if passage.number < passage.total else ""
|
|
290
|
+
return f"{text}\n\n[passage {passage.number} of {passage.total}{following}]"
|
|
291
|
+
|
|
292
|
+
async def search(query: str) -> str:
|
|
293
|
+
found = await store.search(query, hits)
|
|
294
|
+
lines = (f"- {p.id}: {p.document.title}\n {_snippet(p.content, snippet)}" for p in found)
|
|
295
|
+
return "\n".join(lines) or "No matches."
|
|
296
|
+
|
|
297
|
+
how = "by meaning" if store.embedder else "by keywords"
|
|
298
|
+
reading = f"Read one entry of {name} by id; a long one a passage (`id#n`) at a time."
|
|
299
|
+
reader = tool(read, name=f"{name}_read", description=reading)
|
|
300
|
+
searcher = tool(search, name=f"{name}_search", description=f"Search {name} {how}; returns ids to read.")
|
|
301
|
+
|
|
302
|
+
async def consult(request: Request, next: Next) -> AsyncIterator[Event]:
|
|
303
|
+
documents = store.documents()
|
|
304
|
+
full = "\n\n".join(f"### {d.title} ({d.id})\n{d.content}" for d in documents)
|
|
305
|
+
index = "\n".join(f"- {d.id}: {d.title}" for d in documents)
|
|
306
|
+
|
|
307
|
+
if len(full) // chars_per_token <= max_tokens:
|
|
308
|
+
body, tools = full or "(nothing yet)", manage
|
|
309
|
+
elif len(index) // chars_per_token <= max_tokens:
|
|
310
|
+
body, tools = f"{index}\n\nRead an entry with {name}_read.", (reader, *manage)
|
|
311
|
+
else:
|
|
312
|
+
body = f"{len(documents)} entries. Find the relevant ones with {name}_search, then {name}_read them."
|
|
313
|
+
tools = (searcher, reader, *manage)
|
|
314
|
+
|
|
315
|
+
section = f"## {name}\n\n{intro}\n\n{body}"
|
|
316
|
+
system = "\n\n".join(part for part in (request.system, section) if part)
|
|
317
|
+
|
|
318
|
+
async for event in next(replace(request, system=system, tools=(*request.tools, *tools))):
|
|
319
|
+
yield event
|
|
320
|
+
|
|
321
|
+
return consult
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
def _passages(document: Document, size: int, overlap: int) -> list[Passage]:
|
|
325
|
+
"""Cut where a paragraph, or else a word, ends in the second half of the window; the next passage
|
|
326
|
+
starts at a word `overlap` characters before the cut."""
|
|
327
|
+
text, cuts, start = document.content, [], 0
|
|
328
|
+
while len(text) - start > size:
|
|
329
|
+
window = text[start : start + size]
|
|
330
|
+
cut = window.rfind("\n\n", size // 2)
|
|
331
|
+
cut = cut if cut != -1 else window.rfind(" ", size // 2)
|
|
332
|
+
end = start + (cut if cut != -1 else size)
|
|
333
|
+
cuts.append((start, end))
|
|
334
|
+
space = text.find(" ", end - overlap, end)
|
|
335
|
+
start = space + 1 if space != -1 else end - overlap
|
|
336
|
+
cuts.append((start, len(text)))
|
|
337
|
+
total = len(cuts)
|
|
338
|
+
return [
|
|
339
|
+
Passage(document.id if total == 1 else f"{document.id}#{n}", document, n, total, text[a:b].strip())
|
|
340
|
+
for n, (a, b) in enumerate(cuts, 1)
|
|
341
|
+
]
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
def _text(passage: Passage) -> str:
|
|
345
|
+
return f"{passage.document.title}\n\n{passage.content}"
|
|
346
|
+
|
|
347
|
+
|
|
348
|
+
def _bm25(passages: Sequence[Passage], query: str, limit: int, k1: float, b: float) -> list[Passage]:
|
|
349
|
+
"""Rank passages by BM25 over title and text; only positive scores count."""
|
|
350
|
+
bags = [Counter(_tokens(_text(p))) for p in passages]
|
|
351
|
+
total = len(passages)
|
|
352
|
+
average = sum(sum(bag.values()) for bag in bags) / total if total else 0.0
|
|
353
|
+
|
|
354
|
+
def idf(term: str) -> float:
|
|
355
|
+
containing = sum(term in bag for bag in bags)
|
|
356
|
+
return math.log(1 + (total - containing + 0.5) / (containing + 0.5))
|
|
357
|
+
|
|
358
|
+
def score(bag: Counter[str]) -> float:
|
|
359
|
+
length = sum(bag.values())
|
|
360
|
+
return sum(
|
|
361
|
+
idf(term) * bag[term] * (k1 + 1) / (bag[term] + k1 * (1 - b + b * length / average))
|
|
362
|
+
for term in set(_tokens(query))
|
|
363
|
+
if term in bag
|
|
364
|
+
)
|
|
365
|
+
|
|
366
|
+
ranked = sorted(zip(map(score, bags), passages, strict=True), key=lambda pair: -pair[0])
|
|
367
|
+
return [passage for value, passage in ranked[:limit] if value > 0]
|
|
368
|
+
|
|
369
|
+
|
|
370
|
+
def _cosine(a: Sequence[float], b: Sequence[float]) -> float:
|
|
371
|
+
norms = math.sqrt(sum(x * x for x in a)) * math.sqrt(sum(y * y for y in b))
|
|
372
|
+
return sum(x * y for x, y in zip(a, b, strict=True)) / norms if norms else 0.0
|
|
373
|
+
|
|
374
|
+
|
|
375
|
+
def _digest(passage: Passage) -> str:
|
|
376
|
+
return hashlib.sha256(_text(passage).encode()).hexdigest()
|
|
377
|
+
|
|
378
|
+
|
|
379
|
+
_INDEX = TypeAdapter(dict[str, list[float]])
|
|
380
|
+
|
|
381
|
+
_FRONTMATTER = re.compile(r"\A---\n(.*?)\n---\n?", re.DOTALL)
|
|
382
|
+
|
|
383
|
+
|
|
384
|
+
def _split(text: str) -> tuple[dict[str, str], str]:
|
|
385
|
+
if (match := _FRONTMATTER.match(text)) is None:
|
|
386
|
+
return {}, text
|
|
387
|
+
pairs = (line.partition(":") for line in match.group(1).splitlines() if ":" in line)
|
|
388
|
+
return {key.strip(): value.strip().strip("'\"") for key, _, value in pairs}, text[match.end() :]
|
|
389
|
+
|
|
390
|
+
|
|
391
|
+
def _tokens(text: str) -> list[str]:
|
|
392
|
+
return re.findall(r"\w+", text.lower())
|
|
393
|
+
|
|
394
|
+
|
|
395
|
+
def _slug(title: str) -> str:
|
|
396
|
+
return re.sub(r"[^a-z0-9]+", "-", title.lower()).strip("-") or "note"
|
|
397
|
+
|
|
398
|
+
|
|
399
|
+
def _snippet(content: str, length: int) -> str:
|
|
400
|
+
collapsed = " ".join(content.split())
|
|
401
|
+
return collapsed if len(collapsed) <= length else f"{collapsed[:length].rstrip()}…"
|