voidx 1.0.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.
Files changed (126) hide show
  1. voidx/__init__.py +3 -0
  2. voidx/agent/__init__.py +0 -0
  3. voidx/agent/agents.py +439 -0
  4. voidx/agent/attachments.py +235 -0
  5. voidx/agent/graph.py +463 -0
  6. voidx/agent/graph_components/__init__.py +1 -0
  7. voidx/agent/graph_components/compaction.py +268 -0
  8. voidx/agent/graph_components/permissions.py +139 -0
  9. voidx/agent/graph_components/run_loop.py +532 -0
  10. voidx/agent/graph_components/runtime.py +14 -0
  11. voidx/agent/graph_components/streaming.py +351 -0
  12. voidx/agent/graph_components/subagent.py +278 -0
  13. voidx/agent/graph_components/tool_execution.py +208 -0
  14. voidx/agent/runtime_context.py +368 -0
  15. voidx/agent/slash.py +466 -0
  16. voidx/agent/slash_components/__init__.py +1 -0
  17. voidx/agent/slash_components/code_ide.py +68 -0
  18. voidx/agent/slash_components/lsp.py +105 -0
  19. voidx/agent/slash_components/mcp.py +332 -0
  20. voidx/agent/slash_components/model.py +419 -0
  21. voidx/agent/slash_components/runtime.py +55 -0
  22. voidx/agent/slash_components/skills.py +94 -0
  23. voidx/agent/state.py +32 -0
  24. voidx/agent/task_state.py +278 -0
  25. voidx/agent/tool_filters.py +27 -0
  26. voidx/config.py +707 -0
  27. voidx/llm/__init__.py +0 -0
  28. voidx/llm/catalog.py +188 -0
  29. voidx/llm/compaction.py +267 -0
  30. voidx/llm/context.py +43 -0
  31. voidx/llm/instruction.py +220 -0
  32. voidx/llm/provider.py +312 -0
  33. voidx/llm/usage.py +341 -0
  34. voidx/lsp/__init__.py +30 -0
  35. voidx/lsp/client.py +259 -0
  36. voidx/lsp/config.py +172 -0
  37. voidx/lsp/detector.py +512 -0
  38. voidx/lsp/errors.py +19 -0
  39. voidx/lsp/manager.py +280 -0
  40. voidx/lsp/schema.py +179 -0
  41. voidx/lsp/service.py +103 -0
  42. voidx/main.py +154 -0
  43. voidx/mcp/__init__.py +33 -0
  44. voidx/mcp/client.py +458 -0
  45. voidx/mcp/manager.py +267 -0
  46. voidx/mcp/schema.py +112 -0
  47. voidx/mcp/tool.py +122 -0
  48. voidx/mcp_servers/__init__.py +1 -0
  49. voidx/mcp_servers/web.py +104 -0
  50. voidx/memory/__init__.py +0 -0
  51. voidx/memory/context_frames.py +188 -0
  52. voidx/memory/model_profiles.py +98 -0
  53. voidx/memory/runtime_state.py +240 -0
  54. voidx/memory/session.py +272 -0
  55. voidx/memory/store.py +245 -0
  56. voidx/memory/transcript.py +137 -0
  57. voidx/permission/__init__.py +28 -0
  58. voidx/permission/engine.py +430 -0
  59. voidx/permission/evaluate.py +114 -0
  60. voidx/permission/sandbox.py +280 -0
  61. voidx/permission/schema.py +24 -0
  62. voidx/permission/service.py +314 -0
  63. voidx/permission/wildcard.py +34 -0
  64. voidx/skills/__init__.py +18 -0
  65. voidx/skills/bundled/superpowers/receiving-code-review/SKILL.md +30 -0
  66. voidx/skills/bundled/superpowers/requesting-code-review/SKILL.md +27 -0
  67. voidx/skills/bundled/superpowers/systematic-debugging/SKILL.md +36 -0
  68. voidx/skills/bundled/superpowers/test-driven-development/SKILL.md +33 -0
  69. voidx/skills/bundled/superpowers/verification-before-completion/SKILL.md +31 -0
  70. voidx/skills/bundled/superpowers/writing-plans/SKILL.md +27 -0
  71. voidx/skills/policy.py +97 -0
  72. voidx/skills/registry.py +162 -0
  73. voidx/skills/schema.py +47 -0
  74. voidx/skills/service.py +199 -0
  75. voidx/tools/__init__.py +0 -0
  76. voidx/tools/agent.py +81 -0
  77. voidx/tools/base.py +86 -0
  78. voidx/tools/bash.py +105 -0
  79. voidx/tools/file_ops.py +193 -0
  80. voidx/tools/lsp.py +155 -0
  81. voidx/tools/registry.py +104 -0
  82. voidx/tools/repomap.py +238 -0
  83. voidx/tools/search.py +162 -0
  84. voidx/tools/task_status.py +57 -0
  85. voidx/tools/task_tracker.py +81 -0
  86. voidx/tools/todo.py +82 -0
  87. voidx/tools/web_content.py +357 -0
  88. voidx/tools/web_mcp.py +107 -0
  89. voidx/tools/webfetch.py +155 -0
  90. voidx/tools/websearch.py +276 -0
  91. voidx/ui/__init__.py +0 -0
  92. voidx/ui/app.py +1033 -0
  93. voidx/ui/app_components/__init__.py +1 -0
  94. voidx/ui/app_components/clipboard_image.py +245 -0
  95. voidx/ui/app_components/commands.py +18 -0
  96. voidx/ui/app_components/controls.py +29 -0
  97. voidx/ui/app_components/file_picker.py +115 -0
  98. voidx/ui/app_components/formatting.py +187 -0
  99. voidx/ui/app_components/git_changes.py +51 -0
  100. voidx/ui/app_components/rendering.py +1169 -0
  101. voidx/ui/browse.py +160 -0
  102. voidx/ui/capture.py +169 -0
  103. voidx/ui/code_ide.py +251 -0
  104. voidx/ui/commands.py +83 -0
  105. voidx/ui/console.py +381 -0
  106. voidx/ui/console_components/__init__.py +1 -0
  107. voidx/ui/console_components/formatting.py +96 -0
  108. voidx/ui/console_components/streaming.py +253 -0
  109. voidx/ui/diff.py +331 -0
  110. voidx/ui/dock.py +372 -0
  111. voidx/ui/dock_components/__init__.py +1 -0
  112. voidx/ui/dock_components/formatting.py +123 -0
  113. voidx/ui/dock_components/nodes.py +401 -0
  114. voidx/ui/dock_components/state.py +51 -0
  115. voidx/ui/event_components/__init__.py +1 -0
  116. voidx/ui/event_components/schema.py +249 -0
  117. voidx/ui/events.py +341 -0
  118. voidx/ui/session_changes.py +163 -0
  119. voidx/ui/startup.py +161 -0
  120. voidx/ui/transcript.py +148 -0
  121. voidx/ui/tree.py +316 -0
  122. voidx-1.0.0.dist-info/METADATA +59 -0
  123. voidx-1.0.0.dist-info/RECORD +126 -0
  124. voidx-1.0.0.dist-info/WHEEL +5 -0
  125. voidx-1.0.0.dist-info/entry_points.txt +2 -0
  126. voidx-1.0.0.dist-info/top_level.txt +1 -0
voidx/llm/__init__.py ADDED
File without changes
voidx/llm/catalog.py ADDED
@@ -0,0 +1,188 @@
1
+ """Model catalog — typed abstraction over provider model discovery.
2
+
3
+ Each provider registers a fetcher (async callable returning model names).
4
+ Providers without fetchers fall back to STATIC_MODELS.
5
+
6
+ Public interface:
7
+ async def list_models(provider: str) -> list[str]
8
+
9
+ Registering a custom fetcher:
10
+ register_fetcher("my_provider", my_async_fetcher)
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ from collections.abc import Awaitable, Callable
16
+
17
+ import httpx
18
+
19
+ # ── static fallbacks ───────────────────────────────────────────────────────
20
+
21
+ STATIC_MODELS: dict[str, list[str]] = {
22
+ "anthropic": [
23
+ "claude-opus-4-8",
24
+ "claude-sonnet-4-6",
25
+ "claude-opus-4-7",
26
+ "claude-haiku-4-5",
27
+ ],
28
+ "openai": [
29
+ "gpt-5.5",
30
+ "gpt-5.4-mini",
31
+ "gpt-5.4-nano",
32
+ "o3",
33
+ "o4-mini",
34
+ ],
35
+ "deepseek": [
36
+ "deepseek-v4-pro",
37
+ "deepseek-v4-flash",
38
+ ],
39
+ "mimo": [
40
+ "mimo-v2.5-pro",
41
+ "mimo-v2.5",
42
+ "mimo-v2.5-tts",
43
+ ],
44
+ "mimo-token-plan": [
45
+ "mimo-v2.5-pro",
46
+ "mimo-v2.5",
47
+ "mimo-v2.5-tts",
48
+ ],
49
+ "qwen": [
50
+ "qwen3.7-max",
51
+ "qwen3-max",
52
+ "qwen3.6-plus",
53
+ "qwen-plus",
54
+ "qwen-turbo",
55
+ ],
56
+ "zhipu": [
57
+ "glm-5.1",
58
+ "glm-5",
59
+ "glm-4.7",
60
+ "glm-4.7-flash",
61
+ ],
62
+ "kimi": [
63
+ "kimi-k2.6",
64
+ "kimi-k2.5",
65
+ "kimi-k2",
66
+ ],
67
+ "doubao": [
68
+ "doubao-seed-1.6-thinking",
69
+ "doubao-seed-1.6",
70
+ "doubao-seed-1.6-flash",
71
+ ],
72
+ }
73
+
74
+ # ── fetcher registry ───────────────────────────────────────────────────────
75
+
76
+ _fetchers: dict[str, Callable[[], Awaitable[list[str]]]] = {}
77
+
78
+
79
+ def register_fetcher(provider: str, fetcher: Callable[[], Awaitable[list[str]]]) -> None:
80
+ """Register a dynamic fetcher for a provider. Replaces static list."""
81
+ _fetchers[provider] = fetcher
82
+
83
+
84
+ # ── built-in: OpenRouter public API ────────────────────────────────────────
85
+
86
+ OPENROUTER_API = "https://openrouter.ai/api/v1/models"
87
+
88
+
89
+ async def _fetch_openrouter_models() -> list[str]:
90
+ """Fetch models from OpenRouter's public /models endpoint. Free models first,
91
+ filtered to chat/language models only, limited to a manageable count."""
92
+ async with httpx.AsyncClient(timeout=15.0) as client:
93
+ try:
94
+ resp = await client.get(OPENROUTER_API)
95
+ resp.raise_for_status()
96
+ data = resp.json()
97
+ except Exception:
98
+ return STATIC_MODELS.get("openrouter", [])
99
+
100
+ _skip_keywords = (
101
+ "embed", "moderation", "image", "vision", "audio",
102
+ "whisper", "tts", "dall-e", "dalle", "transcribe",
103
+ )
104
+
105
+ free_models: list[str] = []
106
+ paid_models: list[str] = []
107
+ seen: set[str] = set()
108
+
109
+ for entry in data.get("data", []):
110
+ model_id = entry.get("id", "")
111
+ if not model_id:
112
+ continue
113
+ # Skip empty/invalid models
114
+ ctx_len = entry.get("context_length", 0) or 0
115
+ if ctx_len <= 0:
116
+ continue
117
+ # Skip obviously non-chat models
118
+ mid_lower = model_id.lower()
119
+ if any(kw in mid_lower for kw in _skip_keywords):
120
+ continue
121
+ # Dedup: if both x and x:free exist, prefer :free for free list
122
+ base = model_id.removesuffix(":free")
123
+ pricing = entry.get("pricing", {})
124
+ prompt_price = pricing.get("prompt", "-1")
125
+ completion_price = pricing.get("completion", "-1")
126
+ is_free = (
127
+ model_id.endswith(":free")
128
+ or (prompt_price == "0" and completion_price == "0")
129
+ )
130
+ if is_free:
131
+ if base not in seen:
132
+ seen.add(base)
133
+ free_models.append(model_id)
134
+ else:
135
+ if base not in seen:
136
+ seen.add(base)
137
+ paid_models.append(model_id)
138
+
139
+ result = free_models + paid_models
140
+ # Limit total to keep the selector usable
141
+ return result[:100]
142
+
143
+
144
+ register_fetcher("openrouter", _fetch_openrouter_models)
145
+
146
+ # ── custom model support ────────────────────────────────────────────────
147
+
148
+ _settings = None
149
+
150
+ def bind_settings(settings) -> None:
151
+ """Bind a Settings instance so list_models() can merge custom models."""
152
+ global _settings
153
+ _settings = settings
154
+
155
+ def _merge_custom(provider: str, base: list[str]) -> list[str]:
156
+ """Merge custom models (from settings) in front of base list, deduplicating."""
157
+ if _settings is None:
158
+ return base
159
+ custom = _settings.list_custom_models(provider)
160
+ if not custom:
161
+ return base
162
+ seen: set[str] = set()
163
+ result: list[str] = []
164
+ for m in custom + base:
165
+ if m not in seen:
166
+ seen.add(m)
167
+ result.append(m)
168
+ return result
169
+
170
+
171
+ # ── public API ─────────────────────────────────────────────────────────────
172
+
173
+ async def list_models(provider: str) -> list[str]:
174
+ """Return available model names for a provider.
175
+
176
+ If a dynamic fetcher is registered, it's called first. On failure or if
177
+ no fetcher exists, falls back to STATIC_MODELS.
178
+ Custom models from settings are merged in front of the result.
179
+ """
180
+ fetcher = _fetchers.get(provider)
181
+ if fetcher is not None:
182
+ try:
183
+ models = await fetcher()
184
+ if models:
185
+ return _merge_custom(provider, models)
186
+ except Exception:
187
+ pass
188
+ return _merge_custom(provider, STATIC_MODELS.get(provider, []))
@@ -0,0 +1,267 @@
1
+ """Context compaction — three-layer management aligned with opencode.
2
+
3
+ Layer 1 — prune: Truncate old tool outputs. Zero API calls.
4
+ Layer 2 — overflow: Check if total_tokens >= usable_window.
5
+ Layer 3 — compact: LLM-generated structured summary. Preserve tail.
6
+
7
+ Token budget constants (from opencode):
8
+ PRUNE_MINIMUM = 20_000 — minimum to trigger prune
9
+ PRUNE_PROTECT = 40_000 — keep this many tokens of tool output
10
+ COMPACTION_BUFFER = 20_000 — reserved for output
11
+ DEFAULT_TAIL_TURNS = 3 — keep this many recent turns
12
+ MIN_PRESERVE_RECENT = 2_000
13
+ MAX_PRESERVE_RECENT = 8_000
14
+ TOOL_OUTPUT_MAX_CHARS = 2_000
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ from dataclasses import dataclass, field
20
+
21
+ from langchain_core.messages import AIMessage, HumanMessage, SystemMessage
22
+
23
+ from voidx.llm.context import count_tokens, count_messages_tokens
24
+
25
+ PRUNE_MINIMUM = 20_000
26
+ PRUNE_PROTECT = 40_000
27
+ COMPACTION_BUFFER = 20_000
28
+ DEFAULT_TAIL_TURNS = 3
29
+ MIN_PRESERVE_RECENT = 2_000
30
+ MAX_PRESERVE_RECENT = 8_000
31
+ TOOL_OUTPUT_MAX_CHARS = 2_000
32
+ PRUNE_PROTECTED_TOOLS = {"agent"}
33
+
34
+ SUMMARY_TEMPLATE = """Output exactly the Markdown structure shown inside <template> and keep the section order unchanged. Do not include the <template> tags in your response.
35
+ <template>
36
+ ## Goal
37
+ - [single-sentence task summary]
38
+
39
+ ## Constraints & Preferences
40
+ - [user constraints, preferences, specs, or "(none)"]
41
+
42
+ ## Progress
43
+ ### Done
44
+ - [completed work or "(none)"]
45
+
46
+ ### In Progress
47
+ - [current work or "(none)"]
48
+
49
+ ### Blocked
50
+ - [blockers or "(none)"]
51
+
52
+ ## Key Decisions
53
+ - [decision and why, or "(none)"]
54
+
55
+ ## Next Steps
56
+ - [ordered next actions or "(none)"]
57
+
58
+ ## Critical Context
59
+ - [important technical facts, errors, open questions, or "(none)"]
60
+
61
+ ## Relevant Files
62
+ - [file or directory path: why it matters, or "(none)"]
63
+ </template>
64
+
65
+ Rules:
66
+ - Keep every section, even when empty.
67
+ - Use terse bullets, not prose paragraphs.
68
+ - Preserve exact file paths, commands, error strings, and identifiers when known.
69
+ - Do not mention the summary process or that context was compacted."""
70
+
71
+
72
+ @dataclass
73
+ class Turn:
74
+ """A conversation turn starts at a user message and ends before the next."""
75
+ start: int # index in messages list
76
+ end: int # index in messages list (exclusive)
77
+ id: str # user message id
78
+
79
+
80
+ class CompactionService:
81
+ """Manages context window across the agent lifecycle."""
82
+
83
+ def __init__(self, context_limit: int = 128_000, output_token_max: int = 8_192) -> None:
84
+ self.context_limit = context_limit
85
+ self.output_token_max = output_token_max
86
+ self.compaction_count: int = 0
87
+
88
+ # ── token budget helpers ────────────────────────────────────────────
89
+
90
+ def usable_window(self) -> int:
91
+ """How many tokens we can safely use before needing compaction."""
92
+ reserved = COMPACTION_BUFFER
93
+ return max(0, self.context_limit - reserved - self.output_token_max)
94
+
95
+ def preserve_recent_budget(self) -> int:
96
+ """How many tokens worth of messages to preserve as 'tail'."""
97
+ usable = self.usable_window()
98
+ return min(MAX_PRESERVE_RECENT, max(MIN_PRESERVE_RECENT, int(usable * 0.25)))
99
+
100
+ def is_overflow(self, tokens: dict) -> bool:
101
+ """Check if token usage exceeds usable window.
102
+ tokens: {input, output, reasoning, cache_read, cache_write, total}
103
+ """
104
+ usable = self.usable_window()
105
+ if usable <= 0:
106
+ return False
107
+ total = tokens.get("total", 0) or (
108
+ tokens.get("input", 0) +
109
+ tokens.get("output", 0) +
110
+ tokens.get("reasoning", 0)
111
+ )
112
+ return total >= usable
113
+
114
+ # ── Layer 1: prune tool outputs ─────────────────────────────────────
115
+
116
+ def prune(self, messages: list) -> int:
117
+ """Walk backwards through messages, truncating old tool outputs.
118
+ Returns number of characters pruned.
119
+
120
+ Rules:
121
+ - Skip most recent 2 turns (user messages count as turn boundaries)
122
+ - Protected tools (agent) are never pruned
123
+ - Already compacted parts stop further pruning
124
+ - Cumulative tool output > PRUNE_PROTECT → truncate to TOOL_OUTPUT_MAX_CHARS
125
+ - Only prune if total pruned > PRUNE_MINIMUM
126
+ """
127
+ turns_seen = 0
128
+ accumulated = 0
129
+ pruned_chars = 0
130
+ to_prune: list[tuple[int, str]] = [] # (msg_index, truncated_text)
131
+
132
+ for i in range(len(messages) - 1, -1, -1):
133
+ msg = messages[i]
134
+
135
+ # Count turns by user messages
136
+ if isinstance(msg, HumanMessage):
137
+ turns_seen += 1
138
+ if turns_seen > 2:
139
+ continue
140
+
141
+ if isinstance(msg, AIMessage) and hasattr(msg, "summary") and msg.summary:
142
+ break # stop at compaction boundary
143
+
144
+ # Tool messages have role="tool" and a tool_call_id
145
+ if not hasattr(msg, "tool_call_id") or not msg.tool_call_id:
146
+ continue
147
+
148
+ tool_name = getattr(msg, "name", "")
149
+ if tool_name in PRUNE_PROTECTED_TOOLS:
150
+ continue
151
+
152
+ content = str(getattr(msg, "content", ""))
153
+ token_est = count_tokens(content)
154
+
155
+ accumulated += token_est
156
+ if accumulated <= PRUNE_PROTECT:
157
+ continue
158
+
159
+ if len(content) > TOOL_OUTPUT_MAX_CHARS:
160
+ truncated = content[:TOOL_OUTPUT_MAX_CHARS] + (
161
+ f"\n\n[Tool output truncated for context: omitted {len(content) - TOOL_OUTPUT_MAX_CHARS} chars]"
162
+ )
163
+ pruned_chars += len(content) - len(truncated)
164
+ to_prune.append((i, truncated))
165
+
166
+ if pruned_chars > PRUNE_MINIMUM:
167
+ for idx, truncated in to_prune:
168
+ messages[idx] = type(messages[idx])(
169
+ content=truncated,
170
+ tool_call_id=messages[idx].tool_call_id,
171
+ )
172
+ return pruned_chars
173
+
174
+ return 0
175
+
176
+ # ── Layer 3: compaction ─────────────────────────────────────────────
177
+
178
+ def _turns(self, messages: list) -> list[Turn]:
179
+ """Split messages into turns. A turn starts at a user message
180
+ and ends before the next user message."""
181
+ result: list[Turn] = []
182
+ for i, msg in enumerate(messages):
183
+ if isinstance(msg, HumanMessage):
184
+ # Skip synthetic continuation messages
185
+ content = str(getattr(msg, "content", ""))
186
+ if "Continue if you have next steps" in content:
187
+ continue
188
+ result.append(Turn(start=i, end=len(messages), id=getattr(msg, "id", str(i))))
189
+ for j in range(len(result) - 1):
190
+ result[j].end = result[j + 1].start
191
+ return result
192
+
193
+ def select(self, messages: list, tail_turns: int = DEFAULT_TAIL_TURNS) -> tuple[list, str | None]:
194
+ """Split messages into head (to compact) and tail (to keep).
195
+ Returns (head_messages, tail_start_id_or_None).
196
+
197
+ Preserves recent turns up to preserve_recent_budget() tokens.
198
+ """
199
+ budget = self.preserve_recent_budget()
200
+ turns = self._turns(messages)
201
+ if not turns or tail_turns <= 0:
202
+ return messages, None
203
+
204
+ recent = turns[-tail_turns:]
205
+ total = 0
206
+ keep_start: int | None = None
207
+ keep_id: str | None = None
208
+
209
+ for turn in reversed(recent):
210
+ turn_msgs = messages[turn.start:turn.end]
211
+ size = count_messages_tokens(
212
+ [{"role": "assistant" if isinstance(m, AIMessage) else "user",
213
+ "content": str(getattr(m, "content", ""))}
214
+ for m in turn_msgs]
215
+ )
216
+ if total + size <= budget:
217
+ total += size
218
+ keep_start = turn.start
219
+ keep_id = turn.id
220
+ else:
221
+ break
222
+
223
+ if keep_start is None or keep_start == 0:
224
+ return messages, None
225
+
226
+ return messages[:keep_start], keep_id
227
+
228
+ # ── build compaction prompt ─────────────────────────────────────────
229
+
230
+ def build_prompt(self, head_messages: list, previous_summary: str | None = None) -> str:
231
+ """Build the compaction prompt. Extracts user message text from
232
+ head, truncates tool outputs, strips media references."""
233
+ context_parts: list[str] = []
234
+
235
+ for msg in head_messages:
236
+ content = str(getattr(msg, "content", ""))
237
+ if not content.strip():
238
+ continue
239
+
240
+ if isinstance(msg, HumanMessage):
241
+ context_parts.append(f"[User]: {content[:2000]}")
242
+ elif isinstance(msg, AIMessage):
243
+ # Extract text from assistant
244
+ text = content[:2000]
245
+ # Truncate tool calls in this message
246
+ if hasattr(msg, "tool_calls") and msg.tool_calls:
247
+ for tc in msg.tool_calls:
248
+ tc_name = tc.get("name", "?")
249
+ tc_args = str(tc.get("args", {}))[:200]
250
+ text += f"\n [called {tc_name}({tc_args})]"
251
+ context_parts.append(f"[Assistant]: {text}")
252
+ elif hasattr(msg, "tool_call_id"):
253
+ # Tool message — truncate
254
+ truncated = content[:TOOL_OUTPUT_MAX_CHARS]
255
+ if len(content) > TOOL_OUTPUT_MAX_CHARS:
256
+ truncated += f"\n[truncated: {len(content)} chars total]"
257
+ context_parts.append(f"[Tool result]: {truncated}")
258
+
259
+ anchor = (
260
+ "Update the anchored summary below using the conversation history above.\n"
261
+ "Preserve still-true details, remove stale details, and merge in the new facts.\n"
262
+ f"<previous-summary>\n{previous_summary}\n</previous-summary>"
263
+ if previous_summary
264
+ else "Create a new anchored summary from the conversation history above."
265
+ )
266
+
267
+ return "\n\n".join([anchor, SUMMARY_TEMPLATE] + context_parts[:20])
voidx/llm/context.py ADDED
@@ -0,0 +1,43 @@
1
+ """Context window management — token counting, not guessing."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import tiktoken
6
+
7
+
8
+ _ENCODING = None
9
+
10
+
11
+ def _get_encoding():
12
+ global _ENCODING
13
+ if _ENCODING is not None:
14
+ return _ENCODING
15
+ for name in ("cl100k_base", "o200k_base", "gpt2"):
16
+ try:
17
+ _ENCODING = tiktoken.get_encoding(name)
18
+ return _ENCODING
19
+ except Exception:
20
+ continue
21
+ raise RuntimeError("No tiktoken encoding available")
22
+
23
+
24
+ def count_tokens(text: str, model: str = "claude-sonnet-4-6") -> int:
25
+ """Count tokens in text using tiktoken. Deterministic, not estimated."""
26
+ enc = _get_encoding()
27
+ return len(enc.encode(text))
28
+
29
+
30
+ def count_messages_tokens(messages: list[dict], model: str = "claude-sonnet-4-6") -> int:
31
+ """Total token count across all messages."""
32
+ total = 0
33
+ for msg in messages:
34
+ for key, value in msg.items():
35
+ if isinstance(value, str):
36
+ total += count_tokens(value, model)
37
+ elif isinstance(value, list):
38
+ for item in value:
39
+ if isinstance(item, str):
40
+ total += count_tokens(item, model)
41
+ elif isinstance(item, dict):
42
+ total += count_tokens(str(item), model)
43
+ return total