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 ADDED
@@ -0,0 +1 @@
1
+ __version__ = "0.1.0"
@@ -0,0 +1,3 @@
1
+ from contextos.archive.store import ArchiveStore
2
+
3
+ __all__ = ["ArchiveStore"]
@@ -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,3 @@
1
+ from contextos.classifier.rules import Heat, Tagged, classify
2
+
3
+ __all__ = ["Heat", "Tagged", "classify"]
@@ -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,3 @@
1
+ from contextos.compactor.service import CompactionJob, CompactorService
2
+
3
+ __all__ = ["CompactionJob", "CompactorService"]
@@ -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