contextos-dd 0.1.0__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.
- contextos/__init__.py +1 -0
- contextos/archive/__init__.py +3 -0
- contextos/archive/store.py +82 -0
- contextos/classifier/__init__.py +3 -0
- contextos/classifier/rules.py +59 -0
- contextos/cli.py +241 -0
- contextos/compactor/__init__.py +3 -0
- contextos/compactor/prompt.py +25 -0
- contextos/compactor/service.py +124 -0
- contextos/daemon.py +51 -0
- contextos/dashboard/__init__.py +0 -0
- contextos/dashboard/app.py +107 -0
- contextos/dashboard/queries.py +121 -0
- contextos/installer/__init__.py +4 -0
- contextos/installer/detector.py +30 -0
- contextos/installer/patcher.py +107 -0
- contextos/ledger/__init__.py +3 -0
- contextos/ledger/db.py +104 -0
- contextos/ledger/schema.sql +43 -0
- contextos/llm/__init__.py +12 -0
- contextos/llm/download.py +25 -0
- contextos/llm/embedder.py +65 -0
- contextos/llm/generator.py +90 -0
- contextos/llm/warmup.py +38 -0
- contextos/pricing.py +30 -0
- contextos/proxy/__init__.py +13 -0
- contextos/proxy/app.py +270 -0
- contextos/proxy/payload.py +36 -0
- contextos/reconstructor/__init__.py +3 -0
- contextos/reconstructor/rebuild.py +49 -0
- contextos/retrieval/__init__.py +3 -0
- contextos/retrieval/triggers.py +49 -0
- contextos/session_memory.py +60 -0
- contextos/settings.py +87 -0
- contextos/tokens.py +38 -0
- contextos_dd-0.1.0.dist-info/METADATA +160 -0
- contextos_dd-0.1.0.dist-info/RECORD +40 -0
- contextos_dd-0.1.0.dist-info/WHEEL +4 -0
- contextos_dd-0.1.0.dist-info/entry_points.txt +2 -0
- contextos_dd-0.1.0.dist-info/licenses/LICENSE +19 -0
contextos/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.0"
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
import uuid
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
import lancedb
|
|
9
|
+
import pyarrow as pa
|
|
10
|
+
from fastapi.concurrency import run_in_threadpool
|
|
11
|
+
|
|
12
|
+
log = logging.getLogger("contextos.archive")
|
|
13
|
+
|
|
14
|
+
_TABLE = "turns"
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass(slots=True)
|
|
18
|
+
class ArchivedRow:
|
|
19
|
+
archive_id: str
|
|
20
|
+
session_id: str
|
|
21
|
+
content: str
|
|
22
|
+
summary: str
|
|
23
|
+
distance: float = 0.0
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class ArchiveStore:
|
|
27
|
+
"""LanceDB-backed archive for compacted turns. Single table, vector column
|
|
28
|
+
sized to settings.embedding_dim. All writes/reads off the event loop."""
|
|
29
|
+
|
|
30
|
+
def __init__(self, archive_dir: Path, embedding_dim: int) -> None:
|
|
31
|
+
archive_dir.mkdir(parents=True, exist_ok=True)
|
|
32
|
+
self._dim = embedding_dim
|
|
33
|
+
self._db = lancedb.connect(str(archive_dir))
|
|
34
|
+
self._ensure_table()
|
|
35
|
+
|
|
36
|
+
def _ensure_table(self) -> None:
|
|
37
|
+
if _TABLE in self._db.list_tables():
|
|
38
|
+
self._tbl = self._db.open_table(_TABLE)
|
|
39
|
+
return
|
|
40
|
+
schema = pa.schema([
|
|
41
|
+
pa.field("archive_id", pa.string()),
|
|
42
|
+
pa.field("session_id", pa.string()),
|
|
43
|
+
pa.field("content", pa.string()),
|
|
44
|
+
pa.field("summary", pa.string()),
|
|
45
|
+
pa.field("vector", pa.list_(pa.float32(), self._dim)),
|
|
46
|
+
])
|
|
47
|
+
self._tbl = self._db.create_table(_TABLE, schema=schema)
|
|
48
|
+
|
|
49
|
+
async def add(self, session_id: str, content: str, summary: str,
|
|
50
|
+
vector: list[float]) -> str:
|
|
51
|
+
archive_id = uuid.uuid4().hex
|
|
52
|
+
row = [{
|
|
53
|
+
"archive_id": archive_id,
|
|
54
|
+
"session_id": session_id,
|
|
55
|
+
"content": content,
|
|
56
|
+
"summary": summary,
|
|
57
|
+
"vector": vector,
|
|
58
|
+
}]
|
|
59
|
+
await run_in_threadpool(self._tbl.add, row)
|
|
60
|
+
return archive_id
|
|
61
|
+
|
|
62
|
+
async def search(self, session_id: str, query_vector: list[float],
|
|
63
|
+
top_k: int = 3) -> list[ArchivedRow]:
|
|
64
|
+
def _q() -> list[ArchivedRow]:
|
|
65
|
+
df = (
|
|
66
|
+
self._tbl.search(query_vector)
|
|
67
|
+
.where(f"session_id = '{session_id}'")
|
|
68
|
+
.limit(top_k)
|
|
69
|
+
.to_list()
|
|
70
|
+
)
|
|
71
|
+
return [
|
|
72
|
+
ArchivedRow(
|
|
73
|
+
archive_id=r["archive_id"],
|
|
74
|
+
session_id=r["session_id"],
|
|
75
|
+
content=r["content"],
|
|
76
|
+
summary=r["summary"],
|
|
77
|
+
distance=float(r.get("_distance", 0.0)),
|
|
78
|
+
)
|
|
79
|
+
for r in df
|
|
80
|
+
]
|
|
81
|
+
|
|
82
|
+
return await run_in_threadpool(_q)
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from enum import StrEnum
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from contextos.proxy.payload import stringify_content
|
|
9
|
+
|
|
10
|
+
HOT_WINDOW = 5 # last N turns always HOT
|
|
11
|
+
WARM_WINDOW = 15 # turns within this distance from end stay WARM (else COLD)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class Heat(StrEnum):
|
|
15
|
+
HOT = "HOT"
|
|
16
|
+
WARM = "WARM"
|
|
17
|
+
COLD = "COLD"
|
|
18
|
+
DEAD = "DEAD"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass(slots=True)
|
|
22
|
+
class Tagged:
|
|
23
|
+
index: int
|
|
24
|
+
message: dict[str, Any]
|
|
25
|
+
heat: Heat
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _fingerprint(msg: dict[str, Any]) -> str:
|
|
29
|
+
role = str(msg.get("role", ""))
|
|
30
|
+
content = stringify_content(msg.get("content", "")).strip()
|
|
31
|
+
return hashlib.sha256(f"{role}\x1f{content}".encode()).hexdigest()
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def classify(messages: list[dict[str, Any]]) -> list[Tagged]:
|
|
35
|
+
"""Tag each message in-order. Pure function — no I/O, no side effects."""
|
|
36
|
+
n = len(messages)
|
|
37
|
+
tagged: list[Tagged] = []
|
|
38
|
+
seen: set[str] = set()
|
|
39
|
+
|
|
40
|
+
for i, m in enumerate(messages):
|
|
41
|
+
from_end = n - 1 - i
|
|
42
|
+
fp = _fingerprint(m)
|
|
43
|
+
content = stringify_content(m.get("content", "")).strip()
|
|
44
|
+
|
|
45
|
+
# DEAD: byte-identical earlier message, or empty content.
|
|
46
|
+
if not content or fp in seen:
|
|
47
|
+
tagged.append(Tagged(i, m, Heat.DEAD))
|
|
48
|
+
continue
|
|
49
|
+
seen.add(fp)
|
|
50
|
+
|
|
51
|
+
if from_end < HOT_WINDOW:
|
|
52
|
+
heat = Heat.HOT
|
|
53
|
+
elif from_end < WARM_WINDOW:
|
|
54
|
+
heat = Heat.WARM
|
|
55
|
+
else:
|
|
56
|
+
heat = Heat.COLD
|
|
57
|
+
tagged.append(Tagged(i, m, heat))
|
|
58
|
+
|
|
59
|
+
return tagged
|
contextos/cli.py
ADDED
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import subprocess
|
|
4
|
+
import sys
|
|
5
|
+
import time
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
import httpx
|
|
9
|
+
import typer
|
|
10
|
+
from rich.console import Console
|
|
11
|
+
from rich.table import Table
|
|
12
|
+
|
|
13
|
+
from contextos import __version__
|
|
14
|
+
from contextos.daemon import read_pid
|
|
15
|
+
from contextos.daemon import stop as stop_daemon
|
|
16
|
+
from contextos.installer import detect, install_all, uninstall_all
|
|
17
|
+
from contextos.settings import get_settings
|
|
18
|
+
|
|
19
|
+
app = typer.Typer(add_completion=False, help="ContextOS - agentic memory lifecycle manager")
|
|
20
|
+
console = Console()
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _health_url() -> str:
|
|
24
|
+
s = get_settings()
|
|
25
|
+
return f"http://{s.proxy_host}:{s.proxy_port}/_contextos/health"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _is_running() -> bool:
|
|
29
|
+
try:
|
|
30
|
+
r = httpx.get(_health_url(), timeout=1.0)
|
|
31
|
+
return r.status_code == 200
|
|
32
|
+
except httpx.HTTPError:
|
|
33
|
+
return False
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@app.command()
|
|
37
|
+
def version() -> None:
|
|
38
|
+
"""Print version."""
|
|
39
|
+
console.print(f"context-os {__version__}")
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@app.command()
|
|
43
|
+
def status() -> None:
|
|
44
|
+
"""Show daemon and IDE status."""
|
|
45
|
+
s = get_settings()
|
|
46
|
+
running = _is_running()
|
|
47
|
+
pid = read_pid(s.pid_file)
|
|
48
|
+
table = Table(title="ContextOS Status")
|
|
49
|
+
table.add_column("Field")
|
|
50
|
+
table.add_column("Value")
|
|
51
|
+
table.add_row("daemon", "[green]running[/]" if running else "[red]stopped[/]")
|
|
52
|
+
table.add_row("pid", str(pid) if pid else "-")
|
|
53
|
+
table.add_row("proxy", f"http://{s.proxy_host}:{s.proxy_port}")
|
|
54
|
+
table.add_row("data dir", str(s.data_dir))
|
|
55
|
+
console.print(table)
|
|
56
|
+
|
|
57
|
+
targets = detect()
|
|
58
|
+
if targets:
|
|
59
|
+
t = Table(title="Detected IDEs")
|
|
60
|
+
t.add_column("IDE")
|
|
61
|
+
t.add_column("Config")
|
|
62
|
+
for x in targets:
|
|
63
|
+
t.add_row(x.name, str(x.config_path))
|
|
64
|
+
console.print(t)
|
|
65
|
+
else:
|
|
66
|
+
console.print("[yellow]No supported IDE configs found.[/]")
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
@app.command()
|
|
70
|
+
def start(foreground: bool = typer.Option(False, "--foreground", "-f")) -> None:
|
|
71
|
+
"""Start the proxy daemon."""
|
|
72
|
+
if _is_running():
|
|
73
|
+
console.print("[yellow]Already running.[/]")
|
|
74
|
+
return
|
|
75
|
+
if foreground:
|
|
76
|
+
from contextos.daemon import run
|
|
77
|
+
run()
|
|
78
|
+
return
|
|
79
|
+
# Background spawn (detached). Logs go to ~/.contextos/logs/.
|
|
80
|
+
proc = subprocess.Popen(
|
|
81
|
+
[sys.executable, "-m", "contextos.daemon"],
|
|
82
|
+
stdout=subprocess.DEVNULL,
|
|
83
|
+
stderr=subprocess.DEVNULL,
|
|
84
|
+
close_fds=True,
|
|
85
|
+
)
|
|
86
|
+
for _ in range(50):
|
|
87
|
+
if _is_running():
|
|
88
|
+
console.print(f"[green]Daemon started[/] (pid {proc.pid})")
|
|
89
|
+
return
|
|
90
|
+
time.sleep(0.1)
|
|
91
|
+
console.print("[red]Daemon failed to become healthy in time.[/]")
|
|
92
|
+
raise typer.Exit(1)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
@app.command()
|
|
96
|
+
def stop() -> None:
|
|
97
|
+
"""Stop the proxy daemon."""
|
|
98
|
+
if stop_daemon():
|
|
99
|
+
console.print("[green]Stop signal sent.[/]")
|
|
100
|
+
else:
|
|
101
|
+
console.print("[yellow]Daemon not running.[/]")
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
@app.command()
|
|
105
|
+
def install(
|
|
106
|
+
skip_models: bool = typer.Option(
|
|
107
|
+
False, "--skip-models",
|
|
108
|
+
help="Don't download the local LLM + embedder during install "
|
|
109
|
+
"(use for air-gapped installs; first compaction will pull them).",
|
|
110
|
+
),
|
|
111
|
+
) -> None:
|
|
112
|
+
"""Detect supported IDEs, back up their configs, patch base URLs, "
|
|
113
|
+
download local models, start daemon."""
|
|
114
|
+
patched = install_all()
|
|
115
|
+
if not patched:
|
|
116
|
+
console.print("[yellow]No IDE configs were patched (none detected).[/]")
|
|
117
|
+
else:
|
|
118
|
+
for t in patched:
|
|
119
|
+
console.print(f"[green]patched[/] {t.name} ({t.config_path})")
|
|
120
|
+
|
|
121
|
+
if not skip_models:
|
|
122
|
+
console.print("\n[bold]Downloading local models[/] (one time, ~430 MB)")
|
|
123
|
+
try:
|
|
124
|
+
from contextos.llm import warmup as do_warmup
|
|
125
|
+
do_warmup(get_settings(), on_progress=lambda m: console.print(f" {m}"))
|
|
126
|
+
console.print("[green]models ready[/]")
|
|
127
|
+
except Exception as e:
|
|
128
|
+
console.print(f"[yellow]warmup skipped: {e}[/]")
|
|
129
|
+
console.print(" models will be downloaded on first compaction.")
|
|
130
|
+
else:
|
|
131
|
+
console.print("[yellow]--skip-models set; models will download on first use.[/]")
|
|
132
|
+
|
|
133
|
+
if not _is_running():
|
|
134
|
+
start(foreground=False)
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
@app.command()
|
|
138
|
+
def warmup() -> None:
|
|
139
|
+
"""Download the local LLM and embedder into the model cache."""
|
|
140
|
+
from contextos.llm import warmup as do_warmup
|
|
141
|
+
console.print("[bold]Downloading local models[/]")
|
|
142
|
+
try:
|
|
143
|
+
out = do_warmup(get_settings(), on_progress=lambda m: console.print(f" {m}"))
|
|
144
|
+
for label, path in out.items():
|
|
145
|
+
console.print(f"[green]{label}[/]: {path}")
|
|
146
|
+
except Exception as e:
|
|
147
|
+
console.print(f"[red]warmup failed:[/] {e}")
|
|
148
|
+
raise typer.Exit(1) from None
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
@app.command()
|
|
152
|
+
def uninstall() -> None:
|
|
153
|
+
"""Restore IDE configs and stop the daemon."""
|
|
154
|
+
restored = uninstall_all()
|
|
155
|
+
for t in restored:
|
|
156
|
+
console.print(f"[green]restored[/] {t.name}")
|
|
157
|
+
stop_daemon()
|
|
158
|
+
console.print("Done.")
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
@app.command()
|
|
162
|
+
def dashboard(
|
|
163
|
+
port: int = typer.Option(None, help="Override dashboard port (default from settings)"),
|
|
164
|
+
) -> None:
|
|
165
|
+
"""Launch the Streamlit dashboard."""
|
|
166
|
+
s = get_settings()
|
|
167
|
+
bind_port = port or s.dashboard_port
|
|
168
|
+
script = Path(__file__).parent / "dashboard" / "app.py"
|
|
169
|
+
cmd = [
|
|
170
|
+
sys.executable, "-m", "streamlit", "run", str(script),
|
|
171
|
+
"--server.port", str(bind_port),
|
|
172
|
+
"--server.address", s.proxy_host,
|
|
173
|
+
"--server.headless", "true",
|
|
174
|
+
"--browser.gatherUsageStats", "false",
|
|
175
|
+
]
|
|
176
|
+
console.print(f"[green]Launching dashboard[/] at http://{s.proxy_host}:{bind_port}")
|
|
177
|
+
try:
|
|
178
|
+
subprocess.run(cmd, check=False)
|
|
179
|
+
except FileNotFoundError:
|
|
180
|
+
console.print("[red]Streamlit not installed.[/] Run: pip install 'context-os[dashboard]'")
|
|
181
|
+
raise typer.Exit(1) from None
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def _check(label: str, ok: bool, detail: str = "") -> None:
|
|
185
|
+
mark = "[green]OK[/]" if ok else "[red]!![/]"
|
|
186
|
+
console.print(f" {mark} {label}" + (f" [dim]{detail}[/]" if detail else ""))
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def _module_present(name: str) -> bool:
|
|
190
|
+
import importlib.util
|
|
191
|
+
return importlib.util.find_spec(name) is not None
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def _port_free(host: str, port: int) -> bool:
|
|
195
|
+
import socket
|
|
196
|
+
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
|
|
197
|
+
sock.settimeout(0.5)
|
|
198
|
+
return sock.connect_ex((host, port)) != 0
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
@app.command()
|
|
202
|
+
def doctor() -> None:
|
|
203
|
+
"""Run preflight checks: paths, deps, ports, IDE configs, daemon health."""
|
|
204
|
+
s = get_settings()
|
|
205
|
+
console.print("[bold]ContextOS doctor[/]")
|
|
206
|
+
|
|
207
|
+
console.print("\n[bold]Paths[/]")
|
|
208
|
+
_check(f"data dir {s.data_dir}", s.data_dir.exists())
|
|
209
|
+
_check(f"log dir {s.log_dir}", s.log_dir.exists())
|
|
210
|
+
_check(f"db file {s.db_path}", s.db_path.exists(), "created on first request")
|
|
211
|
+
_check(f"model dir {s.model_dir}", s.model_dir.exists())
|
|
212
|
+
|
|
213
|
+
console.print("\n[bold]Dependencies[/]")
|
|
214
|
+
_check("llama-cpp-python", _module_present("llama_cpp"),
|
|
215
|
+
"required for compaction summaries")
|
|
216
|
+
_check("fastembed", _module_present("fastembed"),
|
|
217
|
+
"required for retrieval embeddings")
|
|
218
|
+
_check("lancedb", _module_present("lancedb"))
|
|
219
|
+
_check("streamlit", _module_present("streamlit"),
|
|
220
|
+
"install with: pip install 'context-os[dashboard]'")
|
|
221
|
+
|
|
222
|
+
console.print("\n[bold]Network[/]")
|
|
223
|
+
running = _is_running()
|
|
224
|
+
if running:
|
|
225
|
+
_check(f"proxy http://{s.proxy_host}:{s.proxy_port}", True, "daemon running")
|
|
226
|
+
else:
|
|
227
|
+
_check(f"port {s.proxy_port} free", _port_free(s.proxy_host, s.proxy_port),
|
|
228
|
+
"daemon stopped - port should be free")
|
|
229
|
+
_check(f"dashboard port {s.dashboard_port} free",
|
|
230
|
+
_port_free(s.proxy_host, s.dashboard_port))
|
|
231
|
+
|
|
232
|
+
console.print("\n[bold]IDEs[/]")
|
|
233
|
+
targets = detect()
|
|
234
|
+
if not targets:
|
|
235
|
+
console.print(" [yellow]no supported IDE configs found[/]")
|
|
236
|
+
for t in targets:
|
|
237
|
+
_check(f"{t.name} {t.config_path}", True)
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
if __name__ == "__main__":
|
|
241
|
+
app()
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
SUMMARY_INSTRUCTIONS = """You are a context compactor for a long coding session. Summarise
|
|
4
|
+
the conversation transcript below into a dense, third-person narrative paragraph
|
|
5
|
+
of AT MOST 100 words.
|
|
6
|
+
|
|
7
|
+
You MUST preserve:
|
|
8
|
+
- Entity names (files, functions, classes, variables)
|
|
9
|
+
- Decisions made and the reasoning given for them
|
|
10
|
+
- Breaking changes or API surface modifications
|
|
11
|
+
- File paths that were modified
|
|
12
|
+
- Open questions or unresolved items
|
|
13
|
+
|
|
14
|
+
You MUST omit:
|
|
15
|
+
- Resolved errors and their fixes (unless the fix is itself a load-bearing decision)
|
|
16
|
+
- Reasoning chains that led to a final answer (keep the answer, drop the chain)
|
|
17
|
+
- Polite filler, acknowledgements, status updates
|
|
18
|
+
- Duplicate file contents
|
|
19
|
+
|
|
20
|
+
Output the summary only — no preamble, no markdown headings, no commentary on the task.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def build_summary_prompt(turns_text: str) -> str:
|
|
25
|
+
return f"{SUMMARY_INSTRUCTIONS}\n\n--- TRANSCRIPT ---\n{turns_text}\n--- END ---\n\nSummary:"
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import contextlib
|
|
5
|
+
import logging
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
|
|
8
|
+
from contextos.archive import ArchiveStore
|
|
9
|
+
from contextos.compactor.prompt import build_summary_prompt
|
|
10
|
+
from contextos.llm import Embedder, Generator, LLMUnavailable
|
|
11
|
+
from contextos.proxy.payload import stringify_content
|
|
12
|
+
from contextos.session_memory import SessionMemory
|
|
13
|
+
from contextos.settings import Settings
|
|
14
|
+
|
|
15
|
+
log = logging.getLogger("contextos.compactor")
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass(slots=True)
|
|
19
|
+
class CompactionJob:
|
|
20
|
+
session_id: str
|
|
21
|
+
run_id: str
|
|
22
|
+
messages: list[dict]
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _render_transcript(messages: list[dict]) -> str:
|
|
26
|
+
parts: list[str] = []
|
|
27
|
+
for m in messages:
|
|
28
|
+
role = str(m.get("role", "user"))
|
|
29
|
+
content = stringify_content(m.get("content", "")).strip()
|
|
30
|
+
if content:
|
|
31
|
+
parts.append(f"{role.upper()}: {content}")
|
|
32
|
+
return "\n\n".join(parts)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class CompactorService:
|
|
36
|
+
"""Async, fire-and-forget compaction worker. Never blocks the proxy hot path."""
|
|
37
|
+
|
|
38
|
+
def __init__(
|
|
39
|
+
self,
|
|
40
|
+
settings: Settings,
|
|
41
|
+
generator: Generator,
|
|
42
|
+
embedder: Embedder,
|
|
43
|
+
archive: ArchiveStore,
|
|
44
|
+
memory: SessionMemory,
|
|
45
|
+
) -> None:
|
|
46
|
+
self._settings = settings
|
|
47
|
+
self._generator = generator
|
|
48
|
+
self._embedder = embedder
|
|
49
|
+
self._archive = archive
|
|
50
|
+
self._memory = memory
|
|
51
|
+
self._queue: asyncio.Queue[CompactionJob] = asyncio.Queue(maxsize=128)
|
|
52
|
+
self._task: asyncio.Task | None = None
|
|
53
|
+
self._seen_runs: set[tuple[str, str]] = set()
|
|
54
|
+
|
|
55
|
+
def start(self) -> None:
|
|
56
|
+
if self._task is None or self._task.done():
|
|
57
|
+
self._task = asyncio.create_task(self._worker(), name="contextos-compactor")
|
|
58
|
+
|
|
59
|
+
async def stop(self) -> None:
|
|
60
|
+
if self._task is None:
|
|
61
|
+
return
|
|
62
|
+
self._task.cancel()
|
|
63
|
+
with contextlib.suppress(asyncio.CancelledError):
|
|
64
|
+
await self._task
|
|
65
|
+
self._task = None
|
|
66
|
+
|
|
67
|
+
def submit(self, job: CompactionJob) -> bool:
|
|
68
|
+
key = (job.session_id, job.run_id)
|
|
69
|
+
if key in self._seen_runs:
|
|
70
|
+
return False
|
|
71
|
+
if job.run_id in self._memory.get(job.session_id).summaries:
|
|
72
|
+
return False
|
|
73
|
+
try:
|
|
74
|
+
self._queue.put_nowait(job)
|
|
75
|
+
self._seen_runs.add(key)
|
|
76
|
+
return True
|
|
77
|
+
except asyncio.QueueFull:
|
|
78
|
+
log.warning("compactor queue full; dropping job for %s", job.session_id)
|
|
79
|
+
return False
|
|
80
|
+
|
|
81
|
+
async def _worker(self) -> None:
|
|
82
|
+
while True:
|
|
83
|
+
job = await self._queue.get()
|
|
84
|
+
try:
|
|
85
|
+
await self._process(job)
|
|
86
|
+
except Exception as e:
|
|
87
|
+
log.warning("compaction failed for %s/%s: %s",
|
|
88
|
+
job.session_id, job.run_id, e)
|
|
89
|
+
finally:
|
|
90
|
+
self._queue.task_done()
|
|
91
|
+
|
|
92
|
+
async def _process(self, job: CompactionJob) -> None:
|
|
93
|
+
transcript = _render_transcript(job.messages)
|
|
94
|
+
if not transcript:
|
|
95
|
+
return
|
|
96
|
+
try:
|
|
97
|
+
summary = await self._generator.generate(
|
|
98
|
+
build_summary_prompt(transcript),
|
|
99
|
+
max_tokens=self._settings.llm_max_tokens,
|
|
100
|
+
)
|
|
101
|
+
except LLMUnavailable as e:
|
|
102
|
+
log.info("generator unavailable, skipping compaction: %s", e)
|
|
103
|
+
return
|
|
104
|
+
if not summary:
|
|
105
|
+
return
|
|
106
|
+
|
|
107
|
+
self._memory.put_summary(job.session_id, job.run_id, summary)
|
|
108
|
+
|
|
109
|
+
for m in job.messages:
|
|
110
|
+
content = stringify_content(m.get("content", "")).strip()
|
|
111
|
+
if not content:
|
|
112
|
+
continue
|
|
113
|
+
try:
|
|
114
|
+
vec = await self._embedder.embed(content)
|
|
115
|
+
except LLMUnavailable:
|
|
116
|
+
continue
|
|
117
|
+
if len(vec) != self._settings.embedding_dim:
|
|
118
|
+
log.warning("embedding dim %d != expected %d; skipping archive",
|
|
119
|
+
len(vec), self._settings.embedding_dim)
|
|
120
|
+
continue
|
|
121
|
+
try:
|
|
122
|
+
await self._archive.add(job.session_id, content, summary, vec)
|
|
123
|
+
except Exception as e:
|
|
124
|
+
log.warning("archive add failed: %s", e)
|
contextos/daemon.py
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import contextlib
|
|
4
|
+
import os
|
|
5
|
+
import signal
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
import uvicorn
|
|
9
|
+
|
|
10
|
+
from contextos.proxy import create_app
|
|
11
|
+
from contextos.settings import configure_logging, get_settings
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def run() -> None:
|
|
15
|
+
s = get_settings()
|
|
16
|
+
configure_logging(s)
|
|
17
|
+
s.pid_file.write_text(str(os.getpid()), encoding="utf-8")
|
|
18
|
+
try:
|
|
19
|
+
uvicorn.run(
|
|
20
|
+
create_app(s),
|
|
21
|
+
host=s.proxy_host,
|
|
22
|
+
port=s.proxy_port,
|
|
23
|
+
log_config=None,
|
|
24
|
+
access_log=False,
|
|
25
|
+
)
|
|
26
|
+
finally:
|
|
27
|
+
with contextlib.suppress(FileNotFoundError):
|
|
28
|
+
s.pid_file.unlink()
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def read_pid(pid_file: Path) -> int | None:
|
|
32
|
+
try:
|
|
33
|
+
return int(pid_file.read_text(encoding="utf-8").strip())
|
|
34
|
+
except (FileNotFoundError, ValueError):
|
|
35
|
+
return None
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def stop() -> bool:
|
|
39
|
+
s = get_settings()
|
|
40
|
+
pid = read_pid(s.pid_file)
|
|
41
|
+
if pid is None:
|
|
42
|
+
return False
|
|
43
|
+
try:
|
|
44
|
+
os.kill(pid, signal.SIGTERM)
|
|
45
|
+
return True
|
|
46
|
+
except (OSError, ProcessLookupError):
|
|
47
|
+
return False
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
if __name__ == "__main__":
|
|
51
|
+
run()
|
|
File without changes
|