agent-cli 0.70.5__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.
- agent_cli/__init__.py +5 -0
- agent_cli/__main__.py +6 -0
- agent_cli/_extras.json +14 -0
- agent_cli/_requirements/.gitkeep +0 -0
- agent_cli/_requirements/audio.txt +79 -0
- agent_cli/_requirements/faster-whisper.txt +215 -0
- agent_cli/_requirements/kokoro.txt +425 -0
- agent_cli/_requirements/llm.txt +183 -0
- agent_cli/_requirements/memory.txt +355 -0
- agent_cli/_requirements/mlx-whisper.txt +222 -0
- agent_cli/_requirements/piper.txt +176 -0
- agent_cli/_requirements/rag.txt +402 -0
- agent_cli/_requirements/server.txt +154 -0
- agent_cli/_requirements/speed.txt +77 -0
- agent_cli/_requirements/vad.txt +155 -0
- agent_cli/_requirements/wyoming.txt +71 -0
- agent_cli/_tools.py +368 -0
- agent_cli/agents/__init__.py +23 -0
- agent_cli/agents/_voice_agent_common.py +136 -0
- agent_cli/agents/assistant.py +383 -0
- agent_cli/agents/autocorrect.py +284 -0
- agent_cli/agents/chat.py +496 -0
- agent_cli/agents/memory/__init__.py +31 -0
- agent_cli/agents/memory/add.py +190 -0
- agent_cli/agents/memory/proxy.py +160 -0
- agent_cli/agents/rag_proxy.py +128 -0
- agent_cli/agents/speak.py +209 -0
- agent_cli/agents/transcribe.py +671 -0
- agent_cli/agents/transcribe_daemon.py +499 -0
- agent_cli/agents/voice_edit.py +291 -0
- agent_cli/api.py +22 -0
- agent_cli/cli.py +106 -0
- agent_cli/config.py +503 -0
- agent_cli/config_cmd.py +307 -0
- agent_cli/constants.py +27 -0
- agent_cli/core/__init__.py +1 -0
- agent_cli/core/audio.py +461 -0
- agent_cli/core/audio_format.py +299 -0
- agent_cli/core/chroma.py +88 -0
- agent_cli/core/deps.py +191 -0
- agent_cli/core/openai_proxy.py +139 -0
- agent_cli/core/process.py +195 -0
- agent_cli/core/reranker.py +120 -0
- agent_cli/core/sse.py +87 -0
- agent_cli/core/transcription_logger.py +70 -0
- agent_cli/core/utils.py +526 -0
- agent_cli/core/vad.py +175 -0
- agent_cli/core/watch.py +65 -0
- agent_cli/dev/__init__.py +14 -0
- agent_cli/dev/cli.py +1588 -0
- agent_cli/dev/coding_agents/__init__.py +19 -0
- agent_cli/dev/coding_agents/aider.py +24 -0
- agent_cli/dev/coding_agents/base.py +167 -0
- agent_cli/dev/coding_agents/claude.py +39 -0
- agent_cli/dev/coding_agents/codex.py +24 -0
- agent_cli/dev/coding_agents/continue_dev.py +15 -0
- agent_cli/dev/coding_agents/copilot.py +24 -0
- agent_cli/dev/coding_agents/cursor_agent.py +48 -0
- agent_cli/dev/coding_agents/gemini.py +28 -0
- agent_cli/dev/coding_agents/opencode.py +15 -0
- agent_cli/dev/coding_agents/registry.py +49 -0
- agent_cli/dev/editors/__init__.py +19 -0
- agent_cli/dev/editors/base.py +89 -0
- agent_cli/dev/editors/cursor.py +15 -0
- agent_cli/dev/editors/emacs.py +46 -0
- agent_cli/dev/editors/jetbrains.py +56 -0
- agent_cli/dev/editors/nano.py +31 -0
- agent_cli/dev/editors/neovim.py +33 -0
- agent_cli/dev/editors/registry.py +59 -0
- agent_cli/dev/editors/sublime.py +20 -0
- agent_cli/dev/editors/vim.py +42 -0
- agent_cli/dev/editors/vscode.py +15 -0
- agent_cli/dev/editors/zed.py +20 -0
- agent_cli/dev/project.py +568 -0
- agent_cli/dev/registry.py +52 -0
- agent_cli/dev/skill/SKILL.md +141 -0
- agent_cli/dev/skill/examples.md +571 -0
- agent_cli/dev/terminals/__init__.py +19 -0
- agent_cli/dev/terminals/apple_terminal.py +82 -0
- agent_cli/dev/terminals/base.py +56 -0
- agent_cli/dev/terminals/gnome.py +51 -0
- agent_cli/dev/terminals/iterm2.py +84 -0
- agent_cli/dev/terminals/kitty.py +77 -0
- agent_cli/dev/terminals/registry.py +48 -0
- agent_cli/dev/terminals/tmux.py +58 -0
- agent_cli/dev/terminals/warp.py +132 -0
- agent_cli/dev/terminals/zellij.py +78 -0
- agent_cli/dev/worktree.py +856 -0
- agent_cli/docs_gen.py +417 -0
- agent_cli/example-config.toml +185 -0
- agent_cli/install/__init__.py +5 -0
- agent_cli/install/common.py +89 -0
- agent_cli/install/extras.py +174 -0
- agent_cli/install/hotkeys.py +48 -0
- agent_cli/install/services.py +87 -0
- agent_cli/memory/__init__.py +7 -0
- agent_cli/memory/_files.py +250 -0
- agent_cli/memory/_filters.py +63 -0
- agent_cli/memory/_git.py +157 -0
- agent_cli/memory/_indexer.py +142 -0
- agent_cli/memory/_ingest.py +408 -0
- agent_cli/memory/_persistence.py +182 -0
- agent_cli/memory/_prompt.py +91 -0
- agent_cli/memory/_retrieval.py +294 -0
- agent_cli/memory/_store.py +169 -0
- agent_cli/memory/_streaming.py +44 -0
- agent_cli/memory/_tasks.py +48 -0
- agent_cli/memory/api.py +113 -0
- agent_cli/memory/client.py +272 -0
- agent_cli/memory/engine.py +361 -0
- agent_cli/memory/entities.py +43 -0
- agent_cli/memory/models.py +112 -0
- agent_cli/opts.py +433 -0
- agent_cli/py.typed +0 -0
- agent_cli/rag/__init__.py +3 -0
- agent_cli/rag/_indexer.py +67 -0
- agent_cli/rag/_indexing.py +226 -0
- agent_cli/rag/_prompt.py +30 -0
- agent_cli/rag/_retriever.py +156 -0
- agent_cli/rag/_store.py +48 -0
- agent_cli/rag/_utils.py +218 -0
- agent_cli/rag/api.py +175 -0
- agent_cli/rag/client.py +299 -0
- agent_cli/rag/engine.py +302 -0
- agent_cli/rag/models.py +55 -0
- agent_cli/scripts/.runtime/.gitkeep +0 -0
- agent_cli/scripts/__init__.py +1 -0
- agent_cli/scripts/check_plugin_skill_sync.py +50 -0
- agent_cli/scripts/linux-hotkeys/README.md +63 -0
- agent_cli/scripts/linux-hotkeys/toggle-autocorrect.sh +45 -0
- agent_cli/scripts/linux-hotkeys/toggle-transcription.sh +58 -0
- agent_cli/scripts/linux-hotkeys/toggle-voice-edit.sh +58 -0
- agent_cli/scripts/macos-hotkeys/README.md +45 -0
- agent_cli/scripts/macos-hotkeys/skhd-config-example +5 -0
- agent_cli/scripts/macos-hotkeys/toggle-autocorrect.sh +12 -0
- agent_cli/scripts/macos-hotkeys/toggle-transcription.sh +37 -0
- agent_cli/scripts/macos-hotkeys/toggle-voice-edit.sh +37 -0
- agent_cli/scripts/nvidia-asr-server/README.md +99 -0
- agent_cli/scripts/nvidia-asr-server/pyproject.toml +27 -0
- agent_cli/scripts/nvidia-asr-server/server.py +255 -0
- agent_cli/scripts/nvidia-asr-server/shell.nix +32 -0
- agent_cli/scripts/nvidia-asr-server/uv.lock +4654 -0
- agent_cli/scripts/run-openwakeword.sh +11 -0
- agent_cli/scripts/run-piper-windows.ps1 +30 -0
- agent_cli/scripts/run-piper.sh +24 -0
- agent_cli/scripts/run-whisper-linux.sh +40 -0
- agent_cli/scripts/run-whisper-macos.sh +6 -0
- agent_cli/scripts/run-whisper-windows.ps1 +51 -0
- agent_cli/scripts/run-whisper.sh +9 -0
- agent_cli/scripts/run_faster_whisper_server.py +136 -0
- agent_cli/scripts/setup-linux-hotkeys.sh +72 -0
- agent_cli/scripts/setup-linux.sh +108 -0
- agent_cli/scripts/setup-macos-hotkeys.sh +61 -0
- agent_cli/scripts/setup-macos.sh +76 -0
- agent_cli/scripts/setup-windows.ps1 +63 -0
- agent_cli/scripts/start-all-services-windows.ps1 +53 -0
- agent_cli/scripts/start-all-services.sh +178 -0
- agent_cli/scripts/sync_extras.py +138 -0
- agent_cli/server/__init__.py +3 -0
- agent_cli/server/cli.py +721 -0
- agent_cli/server/common.py +222 -0
- agent_cli/server/model_manager.py +288 -0
- agent_cli/server/model_registry.py +225 -0
- agent_cli/server/proxy/__init__.py +3 -0
- agent_cli/server/proxy/api.py +444 -0
- agent_cli/server/streaming.py +67 -0
- agent_cli/server/tts/__init__.py +3 -0
- agent_cli/server/tts/api.py +335 -0
- agent_cli/server/tts/backends/__init__.py +82 -0
- agent_cli/server/tts/backends/base.py +139 -0
- agent_cli/server/tts/backends/kokoro.py +403 -0
- agent_cli/server/tts/backends/piper.py +253 -0
- agent_cli/server/tts/model_manager.py +201 -0
- agent_cli/server/tts/model_registry.py +28 -0
- agent_cli/server/tts/wyoming_handler.py +249 -0
- agent_cli/server/whisper/__init__.py +3 -0
- agent_cli/server/whisper/api.py +413 -0
- agent_cli/server/whisper/backends/__init__.py +89 -0
- agent_cli/server/whisper/backends/base.py +97 -0
- agent_cli/server/whisper/backends/faster_whisper.py +225 -0
- agent_cli/server/whisper/backends/mlx.py +270 -0
- agent_cli/server/whisper/languages.py +116 -0
- agent_cli/server/whisper/model_manager.py +157 -0
- agent_cli/server/whisper/model_registry.py +28 -0
- agent_cli/server/whisper/wyoming_handler.py +203 -0
- agent_cli/services/__init__.py +343 -0
- agent_cli/services/_wyoming_utils.py +64 -0
- agent_cli/services/asr.py +506 -0
- agent_cli/services/llm.py +228 -0
- agent_cli/services/tts.py +450 -0
- agent_cli/services/wake_word.py +142 -0
- agent_cli-0.70.5.dist-info/METADATA +2118 -0
- agent_cli-0.70.5.dist-info/RECORD +196 -0
- agent_cli-0.70.5.dist-info/WHEEL +4 -0
- agent_cli-0.70.5.dist-info/entry_points.txt +4 -0
- agent_cli-0.70.5.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
"""Persistence logic for memory entries (File + Vector DB)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
from typing import TYPE_CHECKING
|
|
7
|
+
|
|
8
|
+
from agent_cli.memory._files import (
|
|
9
|
+
_DELETED_DIRNAME,
|
|
10
|
+
ensure_store_dirs,
|
|
11
|
+
load_snapshot,
|
|
12
|
+
read_memory_file,
|
|
13
|
+
soft_delete_memory_file,
|
|
14
|
+
write_memory_file,
|
|
15
|
+
write_snapshot,
|
|
16
|
+
)
|
|
17
|
+
from agent_cli.memory._store import delete_entries, list_conversation_entries, upsert_memories
|
|
18
|
+
from agent_cli.memory.entities import Fact, Summary, Turn
|
|
19
|
+
|
|
20
|
+
if TYPE_CHECKING:
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
|
|
23
|
+
from chromadb import Collection
|
|
24
|
+
|
|
25
|
+
from agent_cli.memory.models import MemoryMetadata
|
|
26
|
+
|
|
27
|
+
LOGGER = logging.getLogger(__name__)
|
|
28
|
+
|
|
29
|
+
_SUMMARY_DOC_ID_SUFFIX = "::summary"
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _safe_identifier(value: str) -> str:
|
|
33
|
+
"""File/ID safe token preserving readability."""
|
|
34
|
+
safe = "".join(ch if ch.isalnum() or ch in "-._" else "_" for ch in value)
|
|
35
|
+
return safe or "entry"
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def persist_entries(
|
|
39
|
+
collection: Collection,
|
|
40
|
+
*,
|
|
41
|
+
memory_root: Path,
|
|
42
|
+
conversation_id: str,
|
|
43
|
+
entries: list[Turn | Fact | None],
|
|
44
|
+
) -> None:
|
|
45
|
+
"""Persist a batch of entries to disk and Chroma."""
|
|
46
|
+
ids: list[str] = []
|
|
47
|
+
contents: list[str] = []
|
|
48
|
+
metadatas: list[MemoryMetadata] = []
|
|
49
|
+
|
|
50
|
+
for item in entries:
|
|
51
|
+
if item is None:
|
|
52
|
+
continue
|
|
53
|
+
|
|
54
|
+
if isinstance(item, Turn):
|
|
55
|
+
role: str = item.role
|
|
56
|
+
source_id = None
|
|
57
|
+
elif isinstance(item, Fact):
|
|
58
|
+
role = "memory"
|
|
59
|
+
source_id = item.source_id
|
|
60
|
+
else:
|
|
61
|
+
LOGGER.warning("Unknown entity type in persist_entries: %s", type(item))
|
|
62
|
+
continue
|
|
63
|
+
|
|
64
|
+
record = write_memory_file(
|
|
65
|
+
memory_root,
|
|
66
|
+
conversation_id=conversation_id,
|
|
67
|
+
role=role,
|
|
68
|
+
created_at=item.created_at.isoformat(),
|
|
69
|
+
content=item.content,
|
|
70
|
+
doc_id=item.id,
|
|
71
|
+
source_id=source_id,
|
|
72
|
+
)
|
|
73
|
+
LOGGER.info("Persisted memory file: %s", record.path)
|
|
74
|
+
ids.append(record.id)
|
|
75
|
+
contents.append(record.content)
|
|
76
|
+
metadatas.append(record.metadata)
|
|
77
|
+
|
|
78
|
+
if ids:
|
|
79
|
+
upsert_memories(collection, ids=ids, contents=contents, metadatas=metadatas)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def persist_summary(
|
|
83
|
+
collection: Collection,
|
|
84
|
+
*,
|
|
85
|
+
memory_root: Path,
|
|
86
|
+
summary: Summary,
|
|
87
|
+
) -> None:
|
|
88
|
+
"""Persist a summary to disk and Chroma."""
|
|
89
|
+
doc_id = _safe_identifier(f"{summary.conversation_id}{_SUMMARY_DOC_ID_SUFFIX}-summary")
|
|
90
|
+
record = write_memory_file(
|
|
91
|
+
memory_root,
|
|
92
|
+
conversation_id=summary.conversation_id,
|
|
93
|
+
role="summary",
|
|
94
|
+
created_at=summary.created_at.isoformat(),
|
|
95
|
+
content=summary.content,
|
|
96
|
+
summary_kind="summary",
|
|
97
|
+
doc_id=doc_id,
|
|
98
|
+
)
|
|
99
|
+
upsert_memories(
|
|
100
|
+
collection,
|
|
101
|
+
ids=[record.id],
|
|
102
|
+
contents=[record.content],
|
|
103
|
+
metadatas=[record.metadata],
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def delete_memory_files(
|
|
108
|
+
memory_root: Path,
|
|
109
|
+
conversation_id: str,
|
|
110
|
+
ids: list[str],
|
|
111
|
+
replacement_map: dict[str, str] | None = None,
|
|
112
|
+
) -> None:
|
|
113
|
+
"""Delete markdown files (move to tombstone) and snapshot entries matching the given ids."""
|
|
114
|
+
if not ids:
|
|
115
|
+
return
|
|
116
|
+
|
|
117
|
+
entries_dir, snapshot_path = ensure_store_dirs(memory_root)
|
|
118
|
+
# Ensure we use the correct base for relative paths in soft_delete
|
|
119
|
+
base_entries_dir = entries_dir
|
|
120
|
+
conv_dir = entries_dir / _safe_identifier(conversation_id)
|
|
121
|
+
snapshot = load_snapshot(snapshot_path)
|
|
122
|
+
replacements = replacement_map or {}
|
|
123
|
+
|
|
124
|
+
removed_ids: set[str] = set()
|
|
125
|
+
|
|
126
|
+
# Prefer precise paths from the snapshot.
|
|
127
|
+
for doc_id in ids:
|
|
128
|
+
rec = snapshot.get(doc_id)
|
|
129
|
+
if rec:
|
|
130
|
+
soft_delete_memory_file(
|
|
131
|
+
rec.path,
|
|
132
|
+
base_entries_dir,
|
|
133
|
+
replaced_by=replacements.get(doc_id),
|
|
134
|
+
)
|
|
135
|
+
snapshot.pop(doc_id, None)
|
|
136
|
+
removed_ids.add(doc_id)
|
|
137
|
+
|
|
138
|
+
remaining = {doc_id for doc_id in ids if doc_id not in removed_ids}
|
|
139
|
+
|
|
140
|
+
# Fallback: scan the conversation folder for anything not in the snapshot.
|
|
141
|
+
if remaining and conv_dir.exists():
|
|
142
|
+
for path in conv_dir.rglob("*.md"):
|
|
143
|
+
if _DELETED_DIRNAME in path.parts:
|
|
144
|
+
continue
|
|
145
|
+
rec = read_memory_file(path)
|
|
146
|
+
if rec and rec.id in remaining:
|
|
147
|
+
soft_delete_memory_file(
|
|
148
|
+
path,
|
|
149
|
+
base_entries_dir,
|
|
150
|
+
replaced_by=replacements.get(rec.id),
|
|
151
|
+
)
|
|
152
|
+
snapshot.pop(rec.id, None)
|
|
153
|
+
removed_ids.add(rec.id)
|
|
154
|
+
remaining.remove(rec.id)
|
|
155
|
+
if not remaining:
|
|
156
|
+
break
|
|
157
|
+
|
|
158
|
+
if removed_ids:
|
|
159
|
+
write_snapshot(snapshot_path, snapshot.values())
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def evict_if_needed(
|
|
163
|
+
collection: Collection,
|
|
164
|
+
memory_root: Path,
|
|
165
|
+
conversation_id: str,
|
|
166
|
+
max_entries: int,
|
|
167
|
+
) -> None:
|
|
168
|
+
"""Evict oldest non-summary entries beyond the max budget."""
|
|
169
|
+
if max_entries <= 0:
|
|
170
|
+
return
|
|
171
|
+
entries = list_conversation_entries(collection, conversation_id, include_summary=False)
|
|
172
|
+
if len(entries) <= max_entries:
|
|
173
|
+
return
|
|
174
|
+
# Sort by created_at asc
|
|
175
|
+
sorted_entries = sorted(
|
|
176
|
+
entries,
|
|
177
|
+
key=lambda e: e.metadata.created_at,
|
|
178
|
+
)
|
|
179
|
+
overflow = sorted_entries[:-max_entries]
|
|
180
|
+
ids_to_remove = [e.id for e in overflow]
|
|
181
|
+
delete_entries(collection, ids_to_remove)
|
|
182
|
+
delete_memory_files(memory_root, conversation_id, ids_to_remove)
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
"""Centralized prompts for memory LLM calls."""
|
|
2
|
+
|
|
3
|
+
FACT_SYSTEM_PROMPT = """
|
|
4
|
+
You are a memory extractor. From the latest exchange, return 1-3 concise fact sentences based ONLY on user messages.
|
|
5
|
+
|
|
6
|
+
Guidelines:
|
|
7
|
+
- If there is no meaningful fact, return [].
|
|
8
|
+
- Ignore assistant/system content completely.
|
|
9
|
+
- Facts must be short, readable sentences (e.g., "The user's wife is Anne.", "Planning a trip to Japan next spring.").
|
|
10
|
+
- Do not return acknowledgements, questions, or meta statements; only factual statements from the user.
|
|
11
|
+
- NEVER output refusals like "I cannot..." or "I don't know..." or "I don't have that information". If you can't extract a fact, return [].
|
|
12
|
+
- Return a JSON list of strings.
|
|
13
|
+
|
|
14
|
+
Few-shots:
|
|
15
|
+
- Input: User: "Hi." / Assistant: "Hello" -> []
|
|
16
|
+
- Input: User: "My wife is Anne." / Assistant: "Got it." -> ["The user's wife is Anne."]
|
|
17
|
+
- Input: User: "I like biking on weekends." / Assistant: "Cool!" -> ["User likes biking on weekends."]
|
|
18
|
+
""".strip()
|
|
19
|
+
|
|
20
|
+
FACT_INSTRUCTIONS = """
|
|
21
|
+
Return only factual sentences grounded in the user text. No assistant acknowledgements or meta-text.
|
|
22
|
+
""".strip()
|
|
23
|
+
|
|
24
|
+
UPDATE_MEMORY_PROMPT = """You are a smart memory manager which controls the memory of a system.
|
|
25
|
+
You can perform four operations: (1) ADD into the memory, (2) UPDATE the memory, (3) DELETE from the memory, and (4) NONE (no change).
|
|
26
|
+
|
|
27
|
+
Compare new facts with existing memory. For each new fact, decide whether to:
|
|
28
|
+
- ADD: Add it to the memory as a new element (new information not present in any existing memory)
|
|
29
|
+
- UPDATE: Update an existing memory element (only if facts are about THE SAME TOPIC, e.g., both about pizza preferences)
|
|
30
|
+
- DELETE: Delete an existing memory element (if new fact explicitly contradicts it)
|
|
31
|
+
- NONE: Make no change (if fact is already present, a duplicate, or the existing memory is unrelated to new facts)
|
|
32
|
+
|
|
33
|
+
**Guidelines:**
|
|
34
|
+
|
|
35
|
+
1. **ADD**: If the new fact contains new information not present in any existing memory, add it with a new ID.
|
|
36
|
+
- Existing unrelated memories should have event "NONE".
|
|
37
|
+
- **Example**:
|
|
38
|
+
- Current memory: [{"id": 0, "text": "User is a software engineer"}]
|
|
39
|
+
- New facts: ["Name is John"]
|
|
40
|
+
- Output: [
|
|
41
|
+
{"id": 0, "text": "User is a software engineer", "event": "NONE"},
|
|
42
|
+
{"id": 1, "text": "Name is John", "event": "ADD"}
|
|
43
|
+
]
|
|
44
|
+
|
|
45
|
+
2. **UPDATE**: Only if the new fact refines/expands an existing memory about THE SAME TOPIC.
|
|
46
|
+
- Keep the same ID, update the text.
|
|
47
|
+
- Example: "User likes pizza" + "User loves pepperoni pizza" → UPDATE (same topic: pizza)
|
|
48
|
+
- Example: "Met Sarah today" + "Went running" → NOT same topic, do NOT update!
|
|
49
|
+
- **Example**:
|
|
50
|
+
- Current memory: [{"id": 0, "text": "User likes pizza"}]
|
|
51
|
+
- New facts: ["User loves pepperoni pizza"]
|
|
52
|
+
- Output: [{"id": 0, "text": "User loves pepperoni pizza", "event": "UPDATE"}]
|
|
53
|
+
|
|
54
|
+
3. **DELETE**: If the new fact explicitly contradicts an existing memory.
|
|
55
|
+
- **Example**:
|
|
56
|
+
- Current memory: [{"id": 0, "text": "Loves pizza"}, {"id": 1, "text": "Name is John"}]
|
|
57
|
+
- New facts: ["Hates pizza"]
|
|
58
|
+
- Output: [
|
|
59
|
+
{"id": 0, "text": "Loves pizza", "event": "DELETE"},
|
|
60
|
+
{"id": 1, "text": "Name is John", "event": "NONE"},
|
|
61
|
+
{"id": 2, "text": "Hates pizza", "event": "ADD"}
|
|
62
|
+
]
|
|
63
|
+
|
|
64
|
+
4. **NONE**: If the new fact is already present or existing memory is unrelated to new facts.
|
|
65
|
+
- **Example**:
|
|
66
|
+
- Current memory: [{"id": 0, "text": "Name is John"}]
|
|
67
|
+
- New facts: ["Name is John"]
|
|
68
|
+
- Output: [{"id": 0, "text": "Name is John", "event": "NONE"}]
|
|
69
|
+
|
|
70
|
+
5. **IMPORTANT - Unrelated topics example**:
|
|
71
|
+
- Current memory: [{"id": 0, "text": "Met Sarah to discuss quantum computing"}]
|
|
72
|
+
- New facts: ["Went for a 5km run"]
|
|
73
|
+
- These are COMPLETELY DIFFERENT topics (meeting vs running). Do NOT use UPDATE!
|
|
74
|
+
- Output: [
|
|
75
|
+
{"id": 0, "text": "Met Sarah to discuss quantum computing", "event": "NONE"},
|
|
76
|
+
{"id": 1, "text": "Went for a 5km run", "event": "ADD"}
|
|
77
|
+
]
|
|
78
|
+
|
|
79
|
+
**CRITICAL RULES:**
|
|
80
|
+
- You MUST return ALL memories (existing + new) in your response.
|
|
81
|
+
- Each existing memory MUST have an event (NONE, UPDATE, or DELETE).
|
|
82
|
+
- Each genuinely NEW fact (not related to any existing memory) MUST be ADDed with a new ID.
|
|
83
|
+
- Do NOT use UPDATE for unrelated topics! "Met Sarah" and "Went running" are DIFFERENT topics → use NONE for existing + ADD for new.
|
|
84
|
+
|
|
85
|
+
Return ONLY a JSON list. No prose or code fences.""".strip()
|
|
86
|
+
|
|
87
|
+
SUMMARY_PROMPT = """
|
|
88
|
+
You are a concise conversation summarizer. Update the running summary with the new facts.
|
|
89
|
+
Keep it brief, factual, and focused on durable information; do not restate transient chit-chat.
|
|
90
|
+
Prefer aggregating related facts into compact statements; drop redundancies.
|
|
91
|
+
""".strip()
|
|
@@ -0,0 +1,294 @@
|
|
|
1
|
+
"""Retrieval logic for memory (Reading, Reranking, MMR)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
import math
|
|
7
|
+
from datetime import UTC, datetime
|
|
8
|
+
from typing import TYPE_CHECKING, Any
|
|
9
|
+
|
|
10
|
+
from agent_cli.core.reranker import OnnxCrossEncoder, predict_relevance
|
|
11
|
+
from agent_cli.memory._store import get_summary_entry, query_memories
|
|
12
|
+
from agent_cli.memory.models import (
|
|
13
|
+
ChatRequest,
|
|
14
|
+
MemoryEntry,
|
|
15
|
+
MemoryMetadata,
|
|
16
|
+
MemoryRetrieval,
|
|
17
|
+
Message,
|
|
18
|
+
StoredMemory,
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
if TYPE_CHECKING:
|
|
22
|
+
from chromadb import Collection
|
|
23
|
+
|
|
24
|
+
LOGGER = logging.getLogger(__name__)
|
|
25
|
+
|
|
26
|
+
_DEFAULT_MMR_LAMBDA = 0.7
|
|
27
|
+
_SUMMARY_ROLE = "summary"
|
|
28
|
+
_MIN_MAX_EPSILON = 1e-8 # Avoid division by zero in min-max normalization
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def gather_relevant_existing_memories(
|
|
32
|
+
collection: Collection,
|
|
33
|
+
conversation_id: str,
|
|
34
|
+
new_facts: list[str],
|
|
35
|
+
*,
|
|
36
|
+
neighborhood: int = 5,
|
|
37
|
+
) -> list[StoredMemory]:
|
|
38
|
+
"""Retrieve a small neighborhood of existing memories per new fact, deduped by id."""
|
|
39
|
+
if not new_facts:
|
|
40
|
+
return []
|
|
41
|
+
filters = [
|
|
42
|
+
{"conversation_id": conversation_id},
|
|
43
|
+
{"role": "memory"},
|
|
44
|
+
{"role": {"$ne": "summary"}},
|
|
45
|
+
]
|
|
46
|
+
seen: set[str] = set()
|
|
47
|
+
results: list[StoredMemory] = []
|
|
48
|
+
for fact in new_facts:
|
|
49
|
+
raw = collection.query(query_texts=[fact], n_results=neighborhood, where={"$and": filters})
|
|
50
|
+
docs = raw.get("documents", [[]])[0] or []
|
|
51
|
+
metas = raw.get("metadatas", [[]])[0] or []
|
|
52
|
+
ids = raw.get("ids", [[]])[0] or []
|
|
53
|
+
distances = raw.get("distances", [[]])[0] or []
|
|
54
|
+
for doc, meta, doc_id, dist in zip(docs, metas, ids, distances, strict=False):
|
|
55
|
+
assert doc_id is not None
|
|
56
|
+
if doc_id in seen:
|
|
57
|
+
continue
|
|
58
|
+
seen.add(doc_id)
|
|
59
|
+
norm_meta = MemoryMetadata(**dict(meta))
|
|
60
|
+
results.append(
|
|
61
|
+
StoredMemory(
|
|
62
|
+
id=doc_id,
|
|
63
|
+
content=doc,
|
|
64
|
+
metadata=norm_meta,
|
|
65
|
+
distance=float(dist) if dist is not None else None,
|
|
66
|
+
),
|
|
67
|
+
)
|
|
68
|
+
return results
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def mmr_select(
|
|
72
|
+
candidates: list[StoredMemory],
|
|
73
|
+
scores: list[float],
|
|
74
|
+
*,
|
|
75
|
+
max_items: int,
|
|
76
|
+
lambda_mult: float,
|
|
77
|
+
) -> list[tuple[StoredMemory, float]]:
|
|
78
|
+
"""Apply Maximal Marginal Relevance to promote diversity."""
|
|
79
|
+
if not candidates or max_items <= 0:
|
|
80
|
+
return []
|
|
81
|
+
|
|
82
|
+
def _normalize(vec: list[float] | None) -> list[float] | None:
|
|
83
|
+
if not vec:
|
|
84
|
+
return None
|
|
85
|
+
norm = sum(x * x for x in vec) ** 0.5
|
|
86
|
+
if norm == 0:
|
|
87
|
+
return None
|
|
88
|
+
return [x / norm for x in vec]
|
|
89
|
+
|
|
90
|
+
def _cosine(a: list[float] | None, b: list[float] | None) -> float:
|
|
91
|
+
if not a or not b or len(a) != len(b):
|
|
92
|
+
return 0.0
|
|
93
|
+
return sum(x * y for x, y in zip(a, b, strict=False))
|
|
94
|
+
|
|
95
|
+
normalized_embeddings: list[list[float] | None] = [
|
|
96
|
+
_normalize(mem.embedding) for mem in candidates
|
|
97
|
+
]
|
|
98
|
+
|
|
99
|
+
selected: list[int] = []
|
|
100
|
+
candidate_indices = list(range(len(candidates)))
|
|
101
|
+
|
|
102
|
+
# Start with top scorer
|
|
103
|
+
first_idx = max(candidate_indices, key=lambda i: scores[i])
|
|
104
|
+
selected.append(first_idx)
|
|
105
|
+
candidate_indices.remove(first_idx)
|
|
106
|
+
|
|
107
|
+
while candidate_indices and len(selected) < max_items:
|
|
108
|
+
best_idx = None
|
|
109
|
+
best_score = float("-inf")
|
|
110
|
+
for idx in candidate_indices:
|
|
111
|
+
relevance = scores[idx]
|
|
112
|
+
redundancy = max(
|
|
113
|
+
(_cosine(normalized_embeddings[idx], normalized_embeddings[s]) for s in selected),
|
|
114
|
+
default=0.0,
|
|
115
|
+
)
|
|
116
|
+
mmr_score = lambda_mult * relevance - (1 - lambda_mult) * redundancy
|
|
117
|
+
if mmr_score > best_score:
|
|
118
|
+
best_score = mmr_score
|
|
119
|
+
best_idx = idx
|
|
120
|
+
if best_idx is None:
|
|
121
|
+
break
|
|
122
|
+
selected.append(best_idx)
|
|
123
|
+
candidate_indices.remove(best_idx)
|
|
124
|
+
|
|
125
|
+
return [(candidates[i], scores[i]) for i in selected]
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def retrieve_memory(
|
|
129
|
+
collection: Collection,
|
|
130
|
+
*,
|
|
131
|
+
conversation_id: str,
|
|
132
|
+
query: str,
|
|
133
|
+
top_k: int,
|
|
134
|
+
reranker_model: OnnxCrossEncoder,
|
|
135
|
+
include_global: bool = True,
|
|
136
|
+
include_summary: bool = True,
|
|
137
|
+
mmr_lambda: float = _DEFAULT_MMR_LAMBDA,
|
|
138
|
+
recency_weight: float = 0.2,
|
|
139
|
+
score_threshold: float | None = None,
|
|
140
|
+
filters: dict[str, Any] | None = None,
|
|
141
|
+
) -> tuple[MemoryRetrieval, list[str]]:
|
|
142
|
+
"""Execute search + rerank + recency + MMR."""
|
|
143
|
+
candidate_conversations = [conversation_id]
|
|
144
|
+
if include_global and conversation_id != "global":
|
|
145
|
+
candidate_conversations.append("global")
|
|
146
|
+
|
|
147
|
+
raw_candidates: list[StoredMemory] = []
|
|
148
|
+
seen_ids: set[str] = set()
|
|
149
|
+
|
|
150
|
+
for cid in candidate_conversations:
|
|
151
|
+
records = query_memories(
|
|
152
|
+
collection,
|
|
153
|
+
conversation_id=cid,
|
|
154
|
+
text=query,
|
|
155
|
+
n_results=top_k * 3,
|
|
156
|
+
filters=filters,
|
|
157
|
+
)
|
|
158
|
+
for rec in records:
|
|
159
|
+
rec_id = rec.id
|
|
160
|
+
if rec_id in seen_ids:
|
|
161
|
+
continue
|
|
162
|
+
seen_ids.add(rec_id)
|
|
163
|
+
raw_candidates.append(rec)
|
|
164
|
+
|
|
165
|
+
def _min_max_normalize(scores: list[float]) -> list[float]:
|
|
166
|
+
"""Normalize scores to 0-1 range using min-max scaling."""
|
|
167
|
+
if not scores:
|
|
168
|
+
return scores
|
|
169
|
+
min_score = min(scores)
|
|
170
|
+
max_score = max(scores)
|
|
171
|
+
if max_score - min_score < _MIN_MAX_EPSILON:
|
|
172
|
+
return [0.5] * len(scores) # All scores equal
|
|
173
|
+
return [(s - min_score) / (max_score - min_score) for s in scores]
|
|
174
|
+
|
|
175
|
+
def recency_score(meta: MemoryMetadata) -> float:
|
|
176
|
+
dt = datetime.fromisoformat(meta.created_at)
|
|
177
|
+
age_days = max((datetime.now(UTC) - dt).total_seconds() / 86400.0, 0.0)
|
|
178
|
+
# Exponential decay: ~0.36 score at 30 days
|
|
179
|
+
return math.exp(-age_days / 30.0)
|
|
180
|
+
|
|
181
|
+
final_candidates: list[StoredMemory] = []
|
|
182
|
+
scores: list[float] = []
|
|
183
|
+
|
|
184
|
+
if raw_candidates:
|
|
185
|
+
pairs = [(query, mem.content) for mem in raw_candidates]
|
|
186
|
+
rr_scores = predict_relevance(reranker_model, pairs)
|
|
187
|
+
# Normalize raw reranker scores to 0-1 range
|
|
188
|
+
normalized_scores = _min_max_normalize(rr_scores)
|
|
189
|
+
|
|
190
|
+
for mem, relevance in zip(raw_candidates, normalized_scores, strict=False):
|
|
191
|
+
# Filter out low-relevance memories if threshold is set
|
|
192
|
+
if score_threshold is not None and relevance < score_threshold:
|
|
193
|
+
continue
|
|
194
|
+
|
|
195
|
+
recency = recency_score(mem.metadata)
|
|
196
|
+
# Weighted blend
|
|
197
|
+
total = (1.0 - recency_weight) * relevance + recency_weight * recency
|
|
198
|
+
scores.append(total)
|
|
199
|
+
final_candidates.append(mem)
|
|
200
|
+
|
|
201
|
+
selected = mmr_select(final_candidates, scores, max_items=top_k, lambda_mult=mmr_lambda)
|
|
202
|
+
|
|
203
|
+
entries: list[MemoryEntry] = [
|
|
204
|
+
MemoryEntry(
|
|
205
|
+
role=mem.metadata.role,
|
|
206
|
+
content=mem.content,
|
|
207
|
+
created_at=mem.metadata.created_at,
|
|
208
|
+
score=score,
|
|
209
|
+
)
|
|
210
|
+
for mem, score in selected
|
|
211
|
+
]
|
|
212
|
+
|
|
213
|
+
summaries: list[str] = []
|
|
214
|
+
if include_summary:
|
|
215
|
+
summary_entry = get_summary_entry(collection, conversation_id, role=_SUMMARY_ROLE)
|
|
216
|
+
if summary_entry:
|
|
217
|
+
summaries.append(f"Conversation summary:\n{summary_entry.content}")
|
|
218
|
+
|
|
219
|
+
return MemoryRetrieval(entries=entries), summaries
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def format_augmented_content(
|
|
223
|
+
*,
|
|
224
|
+
user_message: str,
|
|
225
|
+
summaries: list[str],
|
|
226
|
+
memories: list[MemoryEntry],
|
|
227
|
+
) -> str:
|
|
228
|
+
"""Format the prompt content with injected memories."""
|
|
229
|
+
parts: list[str] = []
|
|
230
|
+
if summaries:
|
|
231
|
+
parts.append("Conversation summaries:\n" + "\n\n".join(summaries))
|
|
232
|
+
if memories:
|
|
233
|
+
memory_block = "\n\n---\n\n".join(f"[{m.role}] {m.content}" for m in memories)
|
|
234
|
+
parts.append(f"Long-term memory (most relevant first):\n{memory_block}")
|
|
235
|
+
parts.append(f"Current message: {user_message}")
|
|
236
|
+
return "\n\n---\n\n".join(parts)
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
async def augment_chat_request(
|
|
240
|
+
request: ChatRequest,
|
|
241
|
+
collection: Collection,
|
|
242
|
+
reranker_model: OnnxCrossEncoder,
|
|
243
|
+
default_top_k: int = 5,
|
|
244
|
+
default_memory_id: str = "default",
|
|
245
|
+
include_global: bool = True,
|
|
246
|
+
mmr_lambda: float = _DEFAULT_MMR_LAMBDA,
|
|
247
|
+
recency_weight: float = 0.2,
|
|
248
|
+
score_threshold: float | None = None,
|
|
249
|
+
filters: dict[str, Any] | None = None,
|
|
250
|
+
) -> tuple[ChatRequest, MemoryRetrieval | None, str, list[str]]:
|
|
251
|
+
"""Retrieve memory context and augment the chat request."""
|
|
252
|
+
user_message = next(
|
|
253
|
+
(m.content for m in reversed(request.messages) if m.role == "user"),
|
|
254
|
+
None,
|
|
255
|
+
)
|
|
256
|
+
if not user_message:
|
|
257
|
+
return request, None, default_memory_id, []
|
|
258
|
+
|
|
259
|
+
conversation_id = request.memory_id or default_memory_id
|
|
260
|
+
top_k = request.memory_top_k if request.memory_top_k is not None else default_top_k
|
|
261
|
+
|
|
262
|
+
if top_k <= 0:
|
|
263
|
+
LOGGER.info("Memory retrieval disabled for this request (top_k=%s)", top_k)
|
|
264
|
+
return request, None, conversation_id, []
|
|
265
|
+
|
|
266
|
+
retrieval, summaries = retrieve_memory(
|
|
267
|
+
collection,
|
|
268
|
+
conversation_id=conversation_id,
|
|
269
|
+
query=user_message,
|
|
270
|
+
top_k=top_k,
|
|
271
|
+
reranker_model=reranker_model,
|
|
272
|
+
include_global=include_global,
|
|
273
|
+
mmr_lambda=mmr_lambda,
|
|
274
|
+
recency_weight=recency_weight,
|
|
275
|
+
score_threshold=score_threshold,
|
|
276
|
+
filters=filters,
|
|
277
|
+
)
|
|
278
|
+
|
|
279
|
+
if not retrieval.entries and not summaries:
|
|
280
|
+
return request, None, conversation_id, summaries
|
|
281
|
+
|
|
282
|
+
augmented_content = format_augmented_content(
|
|
283
|
+
user_message=user_message,
|
|
284
|
+
summaries=summaries,
|
|
285
|
+
memories=retrieval.entries,
|
|
286
|
+
)
|
|
287
|
+
|
|
288
|
+
augmented_messages = list(request.messages[:-1])
|
|
289
|
+
augmented_messages.append(Message(role="user", content=augmented_content))
|
|
290
|
+
|
|
291
|
+
aug_request = request.model_copy()
|
|
292
|
+
aug_request.messages = augmented_messages
|
|
293
|
+
|
|
294
|
+
return aug_request, retrieval, conversation_id, summaries
|