differix 0.1.0a1__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 (62) hide show
  1. agents/editor.py +455 -0
  2. agents/researcher.py +405 -0
  3. agents/reviewer.py +374 -0
  4. cli/model_cli.py +430 -0
  5. config.py +557 -0
  6. differix-0.1.0a1.dist-info/METADATA +123 -0
  7. differix-0.1.0a1.dist-info/RECORD +62 -0
  8. differix-0.1.0a1.dist-info/WHEEL +5 -0
  9. differix-0.1.0a1.dist-info/entry_points.txt +2 -0
  10. differix-0.1.0a1.dist-info/top_level.txt +6 -0
  11. main.py +2796 -0
  12. memory/__init__.py +59 -0
  13. memory/embeddings/__init__.py +29 -0
  14. memory/embeddings/factory.py +178 -0
  15. memory/embeddings/providers.py +259 -0
  16. memory/manager.py +186 -0
  17. memory/migrator.py +253 -0
  18. memory/ranker.py +203 -0
  19. memory/schemas.py +127 -0
  20. memory/storage.py +552 -0
  21. tools/agents_chat.py +152 -0
  22. tools/asset_extractor.py +545 -0
  23. tools/asset_manager.py +115 -0
  24. tools/atomic_changeset.py +264 -0
  25. tools/backup_manager.py +122 -0
  26. tools/changelog_engine.py +261 -0
  27. tools/coherence_checker.py +771 -0
  28. tools/compiler.py +531 -0
  29. tools/component_normalizer.py +130 -0
  30. tools/context_assembler.py +631 -0
  31. tools/contract_validator.py +478 -0
  32. tools/cost_predictor.py +141 -0
  33. tools/drift_guard.py +196 -0
  34. tools/file_manager.py +119 -0
  35. tools/handshake.py +351 -0
  36. tools/lesson_manager.py +570 -0
  37. tools/lesson_memory_bridge.py +398 -0
  38. tools/llm_client.py +103 -0
  39. tools/llm_selector.py +362 -0
  40. tools/model_ranker.py +535 -0
  41. tools/palette_extractor.py +1043 -0
  42. tools/plugin_manager.py +204 -0
  43. tools/project_context_gatherer.py +404 -0
  44. tools/project_scanner.py +41 -0
  45. tools/project_structure.py +244 -0
  46. tools/reasoning_logger.py +78 -0
  47. tools/reflection.py +452 -0
  48. tools/sandbox_utils.py +56 -0
  49. tools/security/__init__.py +0 -0
  50. tools/security/behavior_guard.py +83 -0
  51. tools/security/verification_firewall.py +113 -0
  52. tools/security_filter.py +213 -0
  53. tools/session_adr.py +311 -0
  54. tools/skill_manager.py +129 -0
  55. tools/state_manager.py +383 -0
  56. tools/structure_index.py +570 -0
  57. tools/test_schema_consistency.py +19 -0
  58. tools/verification_firewall.py +113 -0
  59. tools/visual_capture.py +65 -0
  60. tools/visual_diff.py +220 -0
  61. tools/visual_reviewer.py +79 -0
  62. tools/web_search.py +371 -0
cli/model_cli.py ADDED
@@ -0,0 +1,430 @@
1
+ import sys
2
+ import os
3
+ import json
4
+ import shlex
5
+ from typing import Dict, Any, List, Optional, Union
6
+
7
+ # NOTE: the actual data/caching layer lives in llm_selector.py (fetches and
8
+ # caches the OpenRouter catalog, and now also owns agent<->model bindings).
9
+ # This used to point at a "core.model_fetcher" / "model_fetcher" module that
10
+ # never existed in this project, which meant every import silently failed
11
+ # and fell through to a hardcoded stub -- complete with a fake cache path
12
+ # llm_selector.py never wrote to, and a fake agent roster ("director",
13
+ # "scriptwriter", "critic", "visual_designer", "voiceover") that has nothing
14
+ # to do with the real pipeline. That's why `llm` showed no context/pricing:
15
+ # it was reading an empty stub, not your real catalog.
16
+ try:
17
+ from core.llm_selector import (
18
+ fetch_openrouter_models,
19
+ get_cached_models,
20
+ get_model_details,
21
+ sync_model_catalog,
22
+ get_agent_bindings,
23
+ save_agent_bindings,
24
+ DEFAULT_AGENTS,
25
+ )
26
+ except ImportError:
27
+ from tools.llm_selector import (
28
+ fetch_openrouter_models,
29
+ get_cached_models,
30
+ get_model_details,
31
+ sync_model_catalog,
32
+ get_agent_bindings,
33
+ save_agent_bindings,
34
+ DEFAULT_AGENTS,
35
+ )
36
+
37
+
38
+ # Terminal ANSI Color codes
39
+ ANSI_RESET = "\033[0m"
40
+ ANSI_BOLD = "\033[1m"
41
+ ANSI_CYAN = "\033[36m"
42
+ ANSI_GREEN = "\033[32m"
43
+ ANSI_YELLOW = "\033[33m"
44
+ ANSI_RED = "\033[31m"
45
+ ANSI_MAGENTA = "\033[35m"
46
+ ANSI_GRAY = "\033[90m"
47
+
48
+
49
+ def format_price(val: Any) -> str:
50
+ """Format an already-per-1M-token price (as llm_selector's catalog stores it)."""
51
+ try:
52
+ per_m = float(val)
53
+ if per_m == 0:
54
+ return "$0.00"
55
+ if per_m < 0.01:
56
+ return f"${per_m:.4f}"
57
+ return f"${per_m:.2f}"
58
+ except (ValueError, TypeError):
59
+ return "N/A"
60
+
61
+
62
+ def format_context(ctx: Any) -> str:
63
+ """Format context length into human-readable representation."""
64
+ try:
65
+ c = int(ctx)
66
+ if c >= 1_000_000:
67
+ return f"{c / 1_000_000:.1f}M"
68
+ if c >= 1000:
69
+ return f"{c // 1000}k"
70
+ return str(c)
71
+ except (ValueError, TypeError):
72
+ return "N/A"
73
+
74
+
75
+ def print_header(title: str) -> None:
76
+ print(f"\n{ANSI_BOLD}{ANSI_CYAN}=== {title} ==={ANSI_RESET}\n")
77
+
78
+
79
+ def display_llm_status() -> None:
80
+ """Display current agent model bindings with context and pricing specifications."""
81
+ print_header("VegeX Multi-Agent LLM Model Bindings")
82
+
83
+ bindings = get_agent_bindings()
84
+ cached_models = get_cached_models() # dict: model_id -> spec
85
+
86
+ header_fmt = "{:<16} {:<38} {:<12} {:<12} {:<12}"
87
+ print(
88
+ ANSI_BOLD
89
+ + header_fmt.format("AGENT", "MODEL ID", "CONTEXT", "INPUT/1M", "OUTPUT/1M")
90
+ + ANSI_RESET
91
+ )
92
+ print("-" * 94)
93
+
94
+ if not cached_models:
95
+ print(f"{ANSI_YELLOW}[!] No local catalog cached yet. Run 'llm sync' to fetch context/pricing data.{ANSI_RESET}\n")
96
+
97
+ for agent, model_id in bindings.items():
98
+ model_info = cached_models.get(model_id)
99
+ if model_info:
100
+ context = format_context(model_info.get("context_length", "N/A"))
101
+ in_cost = format_price(model_info.get("price_input_1m", 0))
102
+ out_cost = format_price(model_info.get("price_output_1m", 0))
103
+ else:
104
+ context = "Unknown"
105
+ in_cost = "Unknown"
106
+ out_cost = "Unknown"
107
+
108
+ print(
109
+ f"{ANSI_GREEN}{agent:<16}{ANSI_RESET} "
110
+ f"{ANSI_BOLD}{model_id:<38}{ANSI_RESET} "
111
+ f"{context:<12} "
112
+ f"{in_cost:<12} "
113
+ f"{out_cost:<12}"
114
+ )
115
+
116
+ print("-" * 94)
117
+ print(f"{ANSI_GRAY}Run 'llm <agent> select' to change an agent's model or 'llm sync' to update catalog.{ANSI_RESET}\n")
118
+
119
+
120
+ def sync_catalog_command() -> None:
121
+ """Force sync local catalog from OpenRouter API."""
122
+ print(f"{ANSI_CYAN}[*] Synchronizing OpenRouter catalog...{ANSI_RESET}")
123
+ models = sync_model_catalog()
124
+ print(f"{ANSI_GREEN}[+] Catalog updated successfully. Total models: {len(models)}{ANSI_RESET}\n")
125
+
126
+
127
+ def filter_models(
128
+ models: Dict[str, Dict[str, Any]],
129
+ query: Optional[str] = None,
130
+ provider: Optional[str] = None,
131
+ max_input_price: Optional[float] = None,
132
+ min_context: Optional[int] = None,
133
+ ) -> List[Dict[str, Any]]:
134
+ """Filter the cached catalog (model_id -> spec dict) by provider, query
135
+ term, price, and context window. Returns a list of specs with 'id'
136
+ folded in, sorted by model_id, for stable display ordering."""
137
+ filtered = []
138
+ for m_id, spec in models.items():
139
+ m_id_l = m_id.lower()
140
+ m_name = str(spec.get("name", "")).lower()
141
+ context_len = spec.get("context_length", 0) or 0
142
+ # Catalog already stores price per 1M tokens (see llm_selector.py) --
143
+ # no per-token conversion here, unlike the raw OpenRouter response.
144
+ prompt_cost = float(spec.get("price_input_1m", 0) or 0)
145
+
146
+ if query:
147
+ q = query.lower()
148
+ if q not in m_id_l and q not in m_name:
149
+ continue
150
+
151
+ if provider:
152
+ p = provider.lower()
153
+ if not m_id_l.startswith(p + "/") and p not in m_id_l:
154
+ continue
155
+
156
+ if max_input_price is not None and prompt_cost > max_input_price:
157
+ continue
158
+
159
+ if min_context is not None and context_len < min_context:
160
+ continue
161
+
162
+ entry = dict(spec)
163
+ entry["id"] = m_id
164
+ filtered.append(entry)
165
+
166
+ filtered.sort(key=lambda e: e["id"])
167
+ return filtered
168
+
169
+
170
+ def interactive_select_model(agent_name: str) -> None:
171
+ """Interactive filter and selection prompt for an agent."""
172
+ models = get_cached_models()
173
+ if not models:
174
+ print(f"{ANSI_YELLOW}[*] No local catalog found. Fetching from OpenRouter...{ANSI_RESET}")
175
+ models = sync_model_catalog()
176
+
177
+ if not models:
178
+ print(f"{ANSI_RED}[!] Error: Failed to retrieve models from catalog.{ANSI_RESET}")
179
+ return
180
+
181
+ bindings = get_agent_bindings()
182
+ current_model = bindings.get(agent_name, "None")
183
+
184
+ print_header(f"Model Selection for Agent: {agent_name.upper()}")
185
+ print(f"Current Binding: {ANSI_GREEN}{current_model}{ANSI_RESET}\n")
186
+
187
+ query = ""
188
+ provider = ""
189
+ max_price = None
190
+ min_context = None
191
+
192
+ while True:
193
+ results = filter_models(
194
+ models,
195
+ query=query if query else None,
196
+ provider=provider if provider else None,
197
+ max_input_price=max_price,
198
+ min_context=min_context,
199
+ )
200
+
201
+ print(f"\n{ANSI_BOLD}Filtered Models ({len(results)} found):{ANSI_RESET}")
202
+ print(f"Filters -> Query: '{query or 'all'}', Provider: '{provider or 'all'}', Max $/1M: {max_price or 'any'}, Min Ctx: {min_context or 'any'}")
203
+ print("-" * 90)
204
+ print(
205
+ ANSI_BOLD
206
+ + "{:<4} {:<42} {:<12} {:<12} {:<12}".format(
207
+ "#", "MODEL ID", "CONTEXT", "INPUT/1M", "OUTPUT/1M"
208
+ )
209
+ + ANSI_RESET
210
+ )
211
+ print("-" * 90)
212
+
213
+ display_slice = results[:20]
214
+ for idx, m in enumerate(display_slice, 1):
215
+ m_id = m.get("id", "")
216
+ ctx = format_context(m.get("context_length", 0))
217
+ in_p = format_price(m.get("price_input_1m", 0))
218
+ out_p = format_price(m.get("price_output_1m", 0))
219
+
220
+ highlight = ANSI_GREEN if m_id == current_model else ""
221
+ reset = ANSI_RESET if m_id == current_model else ""
222
+
223
+ print(
224
+ f"{highlight}{idx:<4} {m_id:<42} {ctx:<12} {in_p:<12} {out_p:<12}{reset}"
225
+ )
226
+
227
+ if len(results) > 20:
228
+ print(f"{ANSI_GRAY}... and {len(results) - 20} more models. Refine search query.{ANSI_RESET}")
229
+
230
+ print("\nCommands / Options:")
231
+ print(" [1-20] : Select model by list number")
232
+ print(" /search <term> : Filter by model name or ID")
233
+ print(" /provider <name> : Filter by provider (e.g. anthropic, openai, meta-llama, mistralai)")
234
+ print(" /maxprice <num> : Max prompt price per 1M tokens (e.g. 5.0)")
235
+ print(" /minctx <num> : Minimum context window (e.g. 128000)")
236
+ print(" /reset : Clear all filters")
237
+ print(" /cancel : Exit without changing")
238
+
239
+ choice = input(f"\n{ANSI_CYAN}Select option or command > {ANSI_RESET}").strip()
240
+
241
+ if not choice or choice.lower() == "/cancel":
242
+ print(f"{ANSI_YELLOW}Selection canceled. Model unchanged.{ANSI_RESET}\n")
243
+ break
244
+
245
+ if choice.lower() == "/reset":
246
+ query = ""
247
+ provider = ""
248
+ max_price = None
249
+ min_context = None
250
+ continue
251
+
252
+ if choice.startswith("/search"):
253
+ parts = choice.split(maxsplit=1)
254
+ query = parts[1].strip() if len(parts) > 1 else ""
255
+ continue
256
+
257
+ if choice.startswith("/provider"):
258
+ parts = choice.split(maxsplit=1)
259
+ provider = parts[1].strip() if len(parts) > 1 else ""
260
+ continue
261
+
262
+ if choice.startswith("/maxprice"):
263
+ parts = choice.split(maxsplit=1)
264
+ if len(parts) > 1:
265
+ try:
266
+ max_price = float(parts[1].strip())
267
+ except ValueError:
268
+ print(f"{ANSI_RED}Invalid price number.{ANSI_RESET}")
269
+ else:
270
+ max_price = None
271
+ continue
272
+
273
+ if choice.startswith("/minctx"):
274
+ parts = choice.split(maxsplit=1)
275
+ if len(parts) > 1:
276
+ try:
277
+ min_context = int(parts[1].strip())
278
+ except ValueError:
279
+ print(f"{ANSI_RED}Invalid context length number.{ANSI_RESET}")
280
+ else:
281
+ min_context = None
282
+ continue
283
+
284
+ if choice.isdigit():
285
+ idx = int(choice)
286
+ if 1 <= idx <= len(display_slice):
287
+ selected = display_slice[idx - 1]
288
+ selected_id = selected.get("id")
289
+ set_agent_model(agent_name, selected_id)
290
+ break
291
+ else:
292
+ print(f"{ANSI_RED}Index out of range. Enter a number between 1 and {len(display_slice)}.{ANSI_RESET}")
293
+ else:
294
+ # Check if user directly typed a model id verbatim
295
+ if choice in models:
296
+ set_agent_model(agent_name, choice)
297
+ break
298
+ else:
299
+ # Default to searching with input
300
+ query = choice
301
+
302
+
303
+ def set_agent_model(agent_name: str, model_id: str) -> None:
304
+ """Set model ID for a specific agent and persist, preserving other bindings."""
305
+ from tools.llm_selector import CONFIG_FILE
306
+
307
+ cfg = {}
308
+ if os.path.exists(CONFIG_FILE):
309
+ try:
310
+ with open(CONFIG_FILE, "r", encoding="utf-8") as f:
311
+ cfg = json.load(f)
312
+ if not isinstance(cfg, dict):
313
+ cfg = {}
314
+ except Exception:
315
+ pass
316
+
317
+ if "bindings" not in cfg:
318
+ cfg["bindings"] = {}
319
+
320
+ cfg["bindings"][agent_name] = model_id
321
+
322
+ try:
323
+ with open(CONFIG_FILE, "w", encoding="utf-8") as f:
324
+ json.dump(cfg, f, indent=2, ensure_ascii=False)
325
+ except Exception as e:
326
+ print(f"{ANSI_RED}Error saving bindings: {e}{ANSI_RESET}")
327
+ return
328
+
329
+ print(f"\n{ANSI_GREEN}[+] Agent '{agent_name}' is now bound to '{model_id}'{ANSI_RESET}\n")
330
+
331
+
332
+ def sync_active_models_specs(agents: Optional[List[str]] = None) -> Dict[str, Any]:
333
+ """Refreshes local pricing/context specs for whichever models are
334
+ currently bound to the active agents, so 'llm status' and cost tracking
335
+ don't keep showing stale/'Unknown' numbers.
336
+
337
+ Only triggers a full catalog re-fetch when the local cache is empty or
338
+ is missing a spec for a currently-bound model - cheap to call on every
339
+ run_agent_cycle() start, which is what main.py does.
340
+
341
+ `agents` optionally restricts the check to a subset of bound agent keys;
342
+ omit it to check every currently bound agent.
343
+ """
344
+ bindings = get_agent_bindings()
345
+ if agents:
346
+ bindings = {a: m for a, m in bindings.items() if a in agents}
347
+
348
+ cached_ids = set(get_cached_models().keys())
349
+ active_ids = {model_id for model_id in bindings.values() if model_id}
350
+ missing_ids = active_ids - cached_ids
351
+
352
+ synced = False
353
+ if not cached_ids or missing_ids:
354
+ sync_model_catalog()
355
+ synced = True
356
+
357
+ return {
358
+ "agents_checked": list(bindings.keys()),
359
+ "models_checked": list(active_ids),
360
+ "synced": synced,
361
+ }
362
+
363
+
364
+ def handle_llm_command(args: Union[str, List[str], None]) -> None:
365
+ """Main CLI router for 'llm' commands.
366
+
367
+ Accepts either a pre-split list of args (["researcher", "select"]) or a
368
+ raw command-line string ("researcher select"), since main.py's REPL
369
+ passes the latter (cmd_line.split(maxsplit=1) leaves the tail as one
370
+ string, and the __main__ entrypoint passes " ".join(sys.argv[2:])).
371
+ Without this, args[0] on a raw string silently returns its first
372
+ character instead of its first word.
373
+ """
374
+ if args is None:
375
+ args = []
376
+ elif isinstance(args, str):
377
+ args = shlex.split(args) if args.strip() else []
378
+
379
+ if not args:
380
+ display_llm_status()
381
+ return
382
+
383
+ subcmd = args[0].lower()
384
+
385
+ if subcmd in ["status", "list", "show"]:
386
+ display_llm_status()
387
+ elif subcmd in ["sync", "refresh", "update"]:
388
+ sync_catalog_command()
389
+ elif subcmd in ["select", "choose", "pick"]:
390
+ if len(args) > 1:
391
+ interactive_select_model(args[1])
392
+ else:
393
+ print("Select an agent:")
394
+ bindings = get_agent_bindings()
395
+ for idx, agent in enumerate(bindings.keys(), 1):
396
+ print(f" {idx}. {agent}")
397
+ choice = input("Enter agent name or number: ").strip()
398
+ agent_list = list(bindings.keys())
399
+ if choice.isdigit() and 1 <= int(choice) <= len(agent_list):
400
+ interactive_select_model(agent_list[int(choice) - 1])
401
+ elif choice in bindings:
402
+ interactive_select_model(choice)
403
+ else:
404
+ print(f"{ANSI_RED}Invalid agent choice.{ANSI_RESET}")
405
+ elif len(args) >= 2 and args[1].lower() in ["select", "pick", "choose"]:
406
+ agent = args[0]
407
+ interactive_select_model(agent)
408
+ elif len(args) >= 3 and args[1].lower() == "set":
409
+ agent = args[0]
410
+ model_id = args[2]
411
+ set_agent_model(agent, model_id)
412
+ elif len(args) == 2 and args[0] not in ["set", "select", "sync", "status"]:
413
+ set_agent_model(args[0], args[1])
414
+ else:
415
+ print(f"{ANSI_RED}Unknown command: {' '.join(args)}{ANSI_RESET}")
416
+ print("Usage:")
417
+ print(" llm Display current bindings")
418
+ print(" llm sync Refresh OpenRouter catalog")
419
+ print(" llm <agent> select Interactive model selector")
420
+ print(" llm <agent> set <model_id> Direct model assignment")
421
+
422
+
423
+ def main() -> None:
424
+ """Standalone entry point for CLI."""
425
+ args = sys.argv[1:]
426
+ handle_llm_command(args)
427
+
428
+
429
+ if __name__ == "__main__":
430
+ main()