deepparallel 0.5.5__tar.gz → 0.5.7__tar.gz

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 (89) hide show
  1. {deepparallel-0.5.5 → deepparallel-0.5.7}/PKG-INFO +1 -1
  2. {deepparallel-0.5.5 → deepparallel-0.5.7}/deepparallel/__init__.py +1 -1
  3. {deepparallel-0.5.5 → deepparallel-0.5.7}/deepparallel/branding.py +3 -3
  4. {deepparallel-0.5.5 → deepparallel-0.5.7}/deepparallel/cli.py +20 -6
  5. {deepparallel-0.5.5 → deepparallel-0.5.7}/deepparallel/config.py +47 -0
  6. deepparallel-0.5.7/deepparallel/memory.py +186 -0
  7. {deepparallel-0.5.5 → deepparallel-0.5.7}/deepparallel/tools/__init__.py +2 -0
  8. deepparallel-0.5.7/deepparallel/tools/git_ops.py +185 -0
  9. {deepparallel-0.5.5 → deepparallel-0.5.7}/deepparallel/tools/mcp.py +69 -2
  10. deepparallel-0.5.7/deepparallel/tools/memory.py +37 -0
  11. {deepparallel-0.5.5 → deepparallel-0.5.7}/deepparallel.egg-info/PKG-INFO +1 -1
  12. {deepparallel-0.5.5 → deepparallel-0.5.7}/deepparallel.egg-info/SOURCES.txt +6 -0
  13. {deepparallel-0.5.5 → deepparallel-0.5.7}/pyproject.toml +1 -1
  14. deepparallel-0.5.7/tests/test_config_file.py +26 -0
  15. deepparallel-0.5.7/tests/test_git_ops.py +116 -0
  16. deepparallel-0.5.7/tests/test_memory.py +68 -0
  17. {deepparallel-0.5.5 → deepparallel-0.5.7}/tests/test_tools_mcp.py +37 -0
  18. {deepparallel-0.5.5 → deepparallel-0.5.7}/README.md +0 -0
  19. {deepparallel-0.5.5 → deepparallel-0.5.7}/deepparallel/agent.py +0 -0
  20. {deepparallel-0.5.5 → deepparallel-0.5.7}/deepparallel/backend.py +0 -0
  21. {deepparallel-0.5.5 → deepparallel-0.5.7}/deepparallel/cockpit.py +0 -0
  22. {deepparallel-0.5.5 → deepparallel-0.5.7}/deepparallel/cockpit_observe.py +0 -0
  23. {deepparallel-0.5.5 → deepparallel-0.5.7}/deepparallel/cockpit_panel.py +0 -0
  24. {deepparallel-0.5.5 → deepparallel-0.5.7}/deepparallel/cockpit_sim.py +0 -0
  25. {deepparallel-0.5.5 → deepparallel-0.5.7}/deepparallel/crowe_id.py +0 -0
  26. {deepparallel-0.5.5 → deepparallel-0.5.7}/deepparallel/dsml.py +0 -0
  27. {deepparallel-0.5.5 → deepparallel-0.5.7}/deepparallel/fusion.py +0 -0
  28. {deepparallel-0.5.5 → deepparallel-0.5.7}/deepparallel/licensing.py +0 -0
  29. {deepparallel-0.5.5 → deepparallel-0.5.7}/deepparallel/registry.json +0 -0
  30. {deepparallel-0.5.5 → deepparallel-0.5.7}/deepparallel/renderer.py +0 -0
  31. {deepparallel-0.5.5 → deepparallel-0.5.7}/deepparallel/research/__init__.py +0 -0
  32. {deepparallel-0.5.5 → deepparallel-0.5.7}/deepparallel/research/conduit.py +0 -0
  33. {deepparallel-0.5.5 → deepparallel-0.5.7}/deepparallel/research/provider.py +0 -0
  34. {deepparallel-0.5.5 → deepparallel-0.5.7}/deepparallel/routing.example.json +0 -0
  35. {deepparallel-0.5.5 → deepparallel-0.5.7}/deepparallel/routing.py +0 -0
  36. {deepparallel-0.5.5 → deepparallel-0.5.7}/deepparallel/serve.py +0 -0
  37. {deepparallel-0.5.5 → deepparallel-0.5.7}/deepparallel/supply_chain.py +0 -0
  38. {deepparallel-0.5.5 → deepparallel-0.5.7}/deepparallel/system_prompt.txt +0 -0
  39. {deepparallel-0.5.5 → deepparallel-0.5.7}/deepparallel/tools/codeast.py +0 -0
  40. {deepparallel-0.5.5 → deepparallel-0.5.7}/deepparallel/tools/edit.py +0 -0
  41. {deepparallel-0.5.5 → deepparallel-0.5.7}/deepparallel/tools/files.py +0 -0
  42. {deepparallel-0.5.5 → deepparallel-0.5.7}/deepparallel/tools/registry.py +0 -0
  43. {deepparallel-0.5.5 → deepparallel-0.5.7}/deepparallel/tools/sandbox.py +0 -0
  44. {deepparallel-0.5.5 → deepparallel-0.5.7}/deepparallel/tools/search.py +0 -0
  45. {deepparallel-0.5.5 → deepparallel-0.5.7}/deepparallel/tools/shell.py +0 -0
  46. {deepparallel-0.5.5 → deepparallel-0.5.7}/deepparallel/tools/vision.py +0 -0
  47. {deepparallel-0.5.5 → deepparallel-0.5.7}/deepparallel/tools/web.py +0 -0
  48. {deepparallel-0.5.5 → deepparallel-0.5.7}/deepparallel/userinput.py +0 -0
  49. {deepparallel-0.5.5 → deepparallel-0.5.7}/deepparallel.egg-info/dependency_links.txt +0 -0
  50. {deepparallel-0.5.5 → deepparallel-0.5.7}/deepparallel.egg-info/entry_points.txt +0 -0
  51. {deepparallel-0.5.5 → deepparallel-0.5.7}/deepparallel.egg-info/requires.txt +0 -0
  52. {deepparallel-0.5.5 → deepparallel-0.5.7}/deepparallel.egg-info/top_level.txt +0 -0
  53. {deepparallel-0.5.5 → deepparallel-0.5.7}/setup.cfg +0 -0
  54. {deepparallel-0.5.5 → deepparallel-0.5.7}/tests/test_agent.py +0 -0
  55. {deepparallel-0.5.5 → deepparallel-0.5.7}/tests/test_backend.py +0 -0
  56. {deepparallel-0.5.5 → deepparallel-0.5.7}/tests/test_backend_chat.py +0 -0
  57. {deepparallel-0.5.5 → deepparallel-0.5.7}/tests/test_backend_stream.py +0 -0
  58. {deepparallel-0.5.5 → deepparallel-0.5.7}/tests/test_branding.py +0 -0
  59. {deepparallel-0.5.5 → deepparallel-0.5.7}/tests/test_cli.py +0 -0
  60. {deepparallel-0.5.5 → deepparallel-0.5.7}/tests/test_cockpit.py +0 -0
  61. {deepparallel-0.5.5 → deepparallel-0.5.7}/tests/test_cockpit_panel.py +0 -0
  62. {deepparallel-0.5.5 → deepparallel-0.5.7}/tests/test_cockpit_sim.py +0 -0
  63. {deepparallel-0.5.5 → deepparallel-0.5.7}/tests/test_config.py +0 -0
  64. {deepparallel-0.5.5 → deepparallel-0.5.7}/tests/test_crowe_backend.py +0 -0
  65. {deepparallel-0.5.5 → deepparallel-0.5.7}/tests/test_crowe_gateway_backend.py +0 -0
  66. {deepparallel-0.5.5 → deepparallel-0.5.7}/tests/test_crowe_id_auth.py +0 -0
  67. {deepparallel-0.5.5 → deepparallel-0.5.7}/tests/test_crowe_payment_required.py +0 -0
  68. {deepparallel-0.5.5 → deepparallel-0.5.7}/tests/test_dsml.py +0 -0
  69. {deepparallel-0.5.5 → deepparallel-0.5.7}/tests/test_fusion.py +0 -0
  70. {deepparallel-0.5.5 → deepparallel-0.5.7}/tests/test_issuer_signer.py +0 -0
  71. {deepparallel-0.5.5 → deepparallel-0.5.7}/tests/test_licensing.py +0 -0
  72. {deepparallel-0.5.5 → deepparallel-0.5.7}/tests/test_renderer.py +0 -0
  73. {deepparallel-0.5.5 → deepparallel-0.5.7}/tests/test_research.py +0 -0
  74. {deepparallel-0.5.5 → deepparallel-0.5.7}/tests/test_research_provider.py +0 -0
  75. {deepparallel-0.5.5 → deepparallel-0.5.7}/tests/test_routing.py +0 -0
  76. {deepparallel-0.5.5 → deepparallel-0.5.7}/tests/test_serve.py +0 -0
  77. {deepparallel-0.5.5 → deepparallel-0.5.7}/tests/test_spinner_color.py +0 -0
  78. {deepparallel-0.5.5 → deepparallel-0.5.7}/tests/test_supply_chain.py +0 -0
  79. {deepparallel-0.5.5 → deepparallel-0.5.7}/tests/test_tool_registry.py +0 -0
  80. {deepparallel-0.5.5 → deepparallel-0.5.7}/tests/test_tools_codeast.py +0 -0
  81. {deepparallel-0.5.5 → deepparallel-0.5.7}/tests/test_tools_edit.py +0 -0
  82. {deepparallel-0.5.5 → deepparallel-0.5.7}/tests/test_tools_files.py +0 -0
  83. {deepparallel-0.5.5 → deepparallel-0.5.7}/tests/test_tools_sandbox.py +0 -0
  84. {deepparallel-0.5.5 → deepparallel-0.5.7}/tests/test_tools_search.py +0 -0
  85. {deepparallel-0.5.5 → deepparallel-0.5.7}/tests/test_tools_shell.py +0 -0
  86. {deepparallel-0.5.5 → deepparallel-0.5.7}/tests/test_tools_vision.py +0 -0
  87. {deepparallel-0.5.5 → deepparallel-0.5.7}/tests/test_tools_web.py +0 -0
  88. {deepparallel-0.5.5 → deepparallel-0.5.7}/tests/test_userinput.py +0 -0
  89. {deepparallel-0.5.5 → deepparallel-0.5.7}/tests/test_userinput_paste.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: deepparallel
3
- Version: 0.5.5
3
+ Version: 0.5.7
4
4
  Summary: DeepParallel - a multi-model agentic coding CLI with cross-model Guardian review, served via Crowe Logic.
5
5
  Author-email: Michael Crowe <michael@crowelogic.com>
6
6
  License: Apache-2.0
@@ -1,3 +1,3 @@
1
1
  """DeepParallel CLI package."""
2
2
 
3
- __version__ = "0.5.5"
3
+ __version__ = "0.5.7"
@@ -373,12 +373,12 @@ def status_text(
373
373
  body.append(f" {DOT} v{version} {DOT} ", style=DIM)
374
374
  body.append("served via Crowe Logic\n", style=f"bold {CROWE_ACCENT}")
375
375
  body.append(f"{tool_count} tools", style=WHITE_HEX)
376
- body.append(f" {DOT} 5,800+ MCP servers", style=DIM)
377
- body.append(f" {DOT} fusion: {fusion}\n", style=DIM)
376
+ body.append(f" {DOT} MCP registry (local + 5,800+)", style=DIM)
377
+ body.append(f" {DOT} memory {DOT} fusion: {fusion}\n", style=DIM)
378
378
  body.append("Backend: ", style=DIM)
379
379
  body.append(f"{backend_label}\n\n", style="white")
380
380
  body.append("/help", style="bold")
381
- body.append(" /tools /fast //deep //fuse ", style=DIM)
381
+ body.append(" /tools /memory /cockpit /fast //deep //fuse ", style=DIM)
382
382
  body.append("/quit", style="bold")
383
383
  return _gutter(body)
384
384
 
@@ -45,7 +45,7 @@ from deepparallel.config import (
45
45
  missing_required,
46
46
  resolve_settings,
47
47
  )
48
- from deepparallel import licensing, supply_chain
48
+ from deepparallel import licensing, memory, supply_chain
49
49
  from deepparallel.fusion import (
50
50
  EscalationBackend,
51
51
  ReasonAnswerBackend,
@@ -177,6 +177,11 @@ def _build_guardian(settings: Settings) -> Backend | None:
177
177
  return backend_for_deployment(settings, settings.guardian_deployment)
178
178
 
179
179
 
180
+ def _system_prompt() -> str:
181
+ """System prompt with the long-term memory index injected (recall tier 1)."""
182
+ return load_system_prompt() + memory.index_block()
183
+
184
+
180
185
  def _make_renderer(*, force_plain: bool, assume_yes: bool = False) -> Renderer:
181
186
  plain = force_plain or _bool_env("DEEPPARALLEL_PLAIN", False) or not sys.stdout.isatty()
182
187
  if plain:
@@ -233,7 +238,7 @@ def _stream_once(backend: Backend, settings: Settings, messages: list[dict]) ->
233
238
 
234
239
  def _stream_repl(backend: Backend, settings: Settings) -> None:
235
240
  """Plain-chat interactive loop (no tools)."""
236
- system = load_system_prompt()
241
+ system = _system_prompt()
237
242
  history: list[tuple[str, str]] = []
238
243
  while True:
239
244
  try:
@@ -289,7 +294,7 @@ def _agent_repl(backend: Backend, settings: Settings, renderer: Renderer) -> Non
289
294
  (/fast //fuse //escalate //deep) swaps the active fusion mode live."""
290
295
  registry = get_registry()
291
296
  guardian = _build_guardian(settings)
292
- system = load_system_prompt()
297
+ system = _system_prompt()
293
298
  messages: list[dict] = [{"role": "system", "content": system}]
294
299
  mode = settings.fusion_mode if settings.fusion_mode in ("reason", "escalate") else "off"
295
300
  deep_next = False
@@ -315,7 +320,7 @@ def _agent_repl(backend: Backend, settings: Settings, renderer: Renderer) -> Non
315
320
  break
316
321
  if user_msg == "/help":
317
322
  branding.info(
318
- "/quit · /reset · /info · /tools · /auto · /cockpit · /fast //fuse //escalate //deep · prompt"
323
+ "/quit · /reset · /info · /tools · /auto · /memory · /cockpit · /fast //fuse //escalate //deep · prompt"
319
324
  )
320
325
  continue
321
326
  if user_msg in {"/auto", "/yes"}:
@@ -354,6 +359,15 @@ def _agent_repl(backend: Backend, settings: Settings, renderer: Renderer) -> Non
354
359
  cockpit.status("done", "cockpit complete")
355
360
  branding.info("cockpit OFF.")
356
361
  continue
362
+ if user_msg == "/memory":
363
+ mems = memory.list_memories()
364
+ if mems:
365
+ branding.info(f"{len(mems)} memories saved. Most recent:")
366
+ for m in mems[-8:]:
367
+ console.print(f" [dim]-[/] {m['name']}: {m['description'][:64]}")
368
+ else:
369
+ branding.info("no memories yet; the agent saves them with the remember tool.")
370
+ continue
357
371
 
358
372
  messages.append({"role": "user", "content": user_msg})
359
373
  if deep_next:
@@ -482,7 +496,7 @@ def run(
482
496
  if fuse is not None:
483
497
  settings = replace(settings, fusion_mode=fuse)
484
498
  backend = _require_ready(settings)
485
- system = load_system_prompt()
499
+ system = _system_prompt()
486
500
  messages = _build_messages([], system, " ".join(prompt))
487
501
  if deep:
488
502
  _run_deep(settings, messages)
@@ -530,7 +544,7 @@ def cockpit(ctx: click.Context, assume_yes: bool, prompt: tuple[str, ...]) -> No
530
544
  cp.status("start", "cockpit online")
531
545
  cp.ensure_panel()
532
546
  backend = _wrap_fusion(backend, settings)
533
- system = load_system_prompt()
547
+ system = _system_prompt()
534
548
  messages = _build_messages([], system, " ".join(prompt))
535
549
  renderer = _make_renderer(force_plain=False, assume_yes=settings.auto_approve)
536
550
  try:
@@ -104,7 +104,54 @@ def _int_env(name: str, default: int) -> int:
104
104
  return default
105
105
 
106
106
 
107
+ _TOML_TO_ENV = {
108
+ "backend.backend": "DEEPPARALLEL_BACKEND",
109
+ "backend.deployment": "DEEPPARALLEL_DEPLOYMENT",
110
+ "backend.temperature": "DEEPPARALLEL_TEMPERATURE",
111
+ "backend.max_tokens": "DEEPPARALLEL_MAX_TOKENS",
112
+ "backend.parallel_models": "DEEPPARALLEL_PARALLEL_MODELS",
113
+ "fusion.mode": "DEEPPARALLEL_FUSION",
114
+ "tools.auto_approve": "DEEPPARALLEL_AUTO_APPROVE",
115
+ "memory.enabled": "DEEPPARALLEL_MEMORY",
116
+ "memory.dir": "DEEPPARALLEL_MEMORY_DIR",
117
+ "mcp.config": "DEEPPARALLEL_MCP_CONFIG",
118
+ }
119
+
120
+
121
+ def _apply_config_file() -> None:
122
+ """Layer ~/.config/deepparallel/config.toml under the environment.
123
+
124
+ Sets each known DEEPPARALLEL_* env var only when unset, so precedence is
125
+ explicit env var > config.toml > built-in default. Missing or malformed
126
+ config is ignored. Path override: DEEPPARALLEL_CONFIG.
127
+ """
128
+ import tomllib
129
+
130
+ path = os.environ.get("DEEPPARALLEL_CONFIG") or os.path.expanduser(
131
+ "~/.config/deepparallel/config.toml"
132
+ )
133
+ try:
134
+ with open(path, "rb") as fh:
135
+ data = tomllib.load(fh)
136
+ except (OSError, ValueError):
137
+ return
138
+ for dotted, env_name in _TOML_TO_ENV.items():
139
+ if os.environ.get(env_name) is not None:
140
+ continue
141
+ section, key = dotted.split(".", 1)
142
+ val = (data.get(section) or {}).get(key)
143
+ if val is None:
144
+ continue
145
+ if isinstance(val, bool):
146
+ os.environ[env_name] = "true" if val else "false"
147
+ elif isinstance(val, list):
148
+ os.environ[env_name] = ",".join(str(x) for x in val)
149
+ else:
150
+ os.environ[env_name] = str(val)
151
+
152
+
107
153
  def resolve_settings() -> Settings:
154
+ _apply_config_file()
108
155
  backend = os.environ.get("DEEPPARALLEL_BACKEND", "azure").strip().lower()
109
156
  if backend not in {"azure", "foundry", "crowe", "openai", "ollama"}:
110
157
  backend = "azure"
@@ -0,0 +1,186 @@
1
+ # Copyright 2026, Crowe Logic Inc.
2
+ # SPDX-License-Identifier: Apache-2.0
3
+
4
+ """Persistent, continuously-growing memory for DeepParallel.
5
+
6
+ This is what makes CroweLM a learning model in practice: durable facts persist
7
+ across sessions and feed back into context. Two recall tiers:
8
+
9
+ - index: MEMORY.md is injected into the system prompt every session, so the
10
+ model remembers the user and their projects without being re-told.
11
+ - retrieval: recall(query) ranks individual memories by relevance (TF-IDF
12
+ cosine over tokens, stdlib only) for an on-demand, semantic-style pull.
13
+
14
+ Memories are markdown files with frontmatter under the memory directory
15
+ (default ~/.config/deepparallel/memory; override DEEPPARALLEL_MEMORY_DIR).
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import math
21
+ import os
22
+ import re
23
+ import time
24
+ from pathlib import Path
25
+
26
+ INDEX_FILE = "MEMORY.md"
27
+ _SLUG_RE = re.compile(r"[^a-z0-9]+")
28
+ _TOK_RE = re.compile(r"[a-z0-9]+")
29
+ _FM_RE = re.compile(r"^---\n(.*?)\n---\n?(.*)$", re.S)
30
+
31
+
32
+ def memory_dir() -> Path:
33
+ raw = os.environ.get("DEEPPARALLEL_MEMORY_DIR") or os.path.expanduser(
34
+ "~/.config/deepparallel/memory"
35
+ )
36
+ return Path(raw)
37
+
38
+
39
+ def enabled() -> bool:
40
+ return os.environ.get("DEEPPARALLEL_MEMORY", "on").strip().lower() not in {
41
+ "0", "off", "false", "no",
42
+ }
43
+
44
+
45
+ def _slugify(name: str) -> str:
46
+ return _SLUG_RE.sub("-", name.strip().lower()).strip("-") or "memory"
47
+
48
+
49
+ def _unique_slug(d: Path, slug: str) -> str:
50
+ cand, i = slug, 2
51
+ while (d / f"{cand}.md").exists():
52
+ cand, i = f"{slug}-{i}", i + 1
53
+ return cand
54
+
55
+
56
+ def load_index() -> str:
57
+ try:
58
+ return (memory_dir() / INDEX_FILE).read_text(encoding="utf-8").strip()
59
+ except OSError:
60
+ return ""
61
+
62
+
63
+ def index_block() -> str:
64
+ """The memory index formatted for system-prompt injection (empty if none)."""
65
+ idx = load_index()
66
+ if not idx or not enabled():
67
+ return ""
68
+ return (
69
+ "\n\nLong-term memory (recalled from past sessions; background context, "
70
+ "verify file/line specifics before relying on them):\n" + idx
71
+ )
72
+
73
+
74
+ def save_memory(content: str, name: str = "", description: str = "", mtype: str = "note") -> str:
75
+ """Write one durable fact as a markdown file and add an index line."""
76
+ d = memory_dir()
77
+ d.mkdir(parents=True, exist_ok=True)
78
+ first_line = content.strip().split("\n", 1)[0]
79
+ title = (name or first_line)[:60].strip() or "memory"
80
+ slug = _unique_slug(d, _slugify(title))
81
+ desc = (description or first_line).replace("\n", " ").strip()[:120]
82
+ body = (
83
+ f"---\nname: {title}\ndescription: {desc}\ntype: {mtype}\n"
84
+ f"created: {int(time.time())}\n---\n\n{content.strip()}\n"
85
+ )
86
+ (d / f"{slug}.md").write_text(body, encoding="utf-8")
87
+ _append_index(d, slug, title, desc)
88
+ return slug
89
+
90
+
91
+ def _append_index(d: Path, slug: str, title: str, desc: str) -> None:
92
+ idx = d / INDEX_FILE
93
+ existing = idx.read_text(encoding="utf-8") if idx.exists() else "# DeepParallel Memory\n\n"
94
+ if f"]({slug}.md)" in existing:
95
+ return
96
+ if not existing.endswith("\n"):
97
+ existing += "\n"
98
+ idx.write_text(existing + f"- [{title}]({slug}.md) - {desc}\n", encoding="utf-8")
99
+
100
+
101
+ def _parse(text: str, fallback: str) -> tuple[str, str, str]:
102
+ m = _FM_RE.match(text)
103
+ if not m:
104
+ return fallback, "", text.strip()
105
+ meta = {}
106
+ for line in m.group(1).splitlines():
107
+ if ":" in line:
108
+ k, v = line.split(":", 1)
109
+ meta[k.strip()] = v.strip()
110
+ return meta.get("name", fallback), meta.get("description", ""), m.group(2).strip()
111
+
112
+
113
+ def list_memories() -> list[dict]:
114
+ d = memory_dir()
115
+ out = []
116
+ if not d.exists():
117
+ return out
118
+ for p in sorted(d.glob("*.md")):
119
+ if p.name == INDEX_FILE:
120
+ continue
121
+ name, desc, body = _parse(p.read_text(encoding="utf-8", errors="replace"), p.stem)
122
+ out.append({"name": name, "description": desc, "content": body, "path": str(p)})
123
+ return out
124
+
125
+
126
+ def _tokenize(text: str) -> list[str]:
127
+ return _TOK_RE.findall(text.lower())
128
+
129
+
130
+ def recall(query: str, limit: int = 5) -> list[dict]:
131
+ """Relevance-ranked memories via TF-IDF cosine over tokens (stdlib only)."""
132
+ mems = list_memories()
133
+ if not mems or not query.strip():
134
+ return []
135
+ docs = [_tokenize(f"{m['name']} {m['description']} {m['content']}") for m in mems]
136
+ df: dict[str, int] = {}
137
+ for toks in docs:
138
+ for t in set(toks):
139
+ df[t] = df.get(t, 0) + 1
140
+ n = len(docs)
141
+
142
+ def vec(toks: list[str]) -> dict[str, float]:
143
+ if not toks:
144
+ return {}
145
+ tf: dict[str, int] = {}
146
+ for t in toks:
147
+ tf[t] = tf.get(t, 0) + 1
148
+ return {t: (c / len(toks)) * math.log((n + 1) / (df.get(t, 0) + 1) + 1) for t, c in tf.items()}
149
+
150
+ qv = vec(_tokenize(query))
151
+ qa = math.sqrt(sum(v * v for v in qv.values()))
152
+ scored = []
153
+ for m, toks in zip(mems, docs):
154
+ dv = vec(toks)
155
+ da = math.sqrt(sum(v * v for v in dv.values()))
156
+ num = sum(qv.get(t, 0.0) * dv.get(t, 0.0) for t in qv)
157
+ score = num / (da * qa) if da and qa else 0.0
158
+ if score > 0:
159
+ scored.append(
160
+ {"name": m["name"], "description": m["description"], "content": m["content"],
161
+ "score": round(score, 4)}
162
+ )
163
+ scored.sort(key=lambda x: x["score"], reverse=True)
164
+ return scored[:limit]
165
+
166
+
167
+ def _rebuild_index(d: Path) -> None:
168
+ lines = ["# DeepParallel Memory", ""]
169
+ for m in list_memories():
170
+ lines.append(f"- [{m['name']}]({Path(m['path']).stem}.md) - {m['description']}")
171
+ (d / INDEX_FILE).write_text("\n".join(lines) + "\n", encoding="utf-8")
172
+
173
+
174
+ def forget(name: str) -> bool:
175
+ d = memory_dir()
176
+ target = d / f"{_slugify(name)}.md"
177
+ if not target.exists():
178
+ for p in d.glob("*.md"):
179
+ if p.stem == name and p.name != INDEX_FILE:
180
+ target = p
181
+ break
182
+ if target.exists() and target.name != INDEX_FILE:
183
+ target.unlink()
184
+ _rebuild_index(d)
185
+ return True
186
+ return False
@@ -19,7 +19,9 @@ from deepparallel.tools import ( # noqa: E402,F401
19
19
  codeast,
20
20
  edit,
21
21
  files,
22
+ git_ops,
22
23
  mcp,
24
+ memory,
23
25
  sandbox,
24
26
  search,
25
27
  shell,
@@ -0,0 +1,185 @@
1
+ """Git operations tools: status, diff, log, branch, add, commit, clone."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import shlex
7
+ import subprocess
8
+ from pathlib import Path
9
+
10
+ from deepparallel.tools import tool
11
+
12
+
13
+ def _repo(repo_path: str) -> Path:
14
+ return Path(repo_path).expanduser().resolve()
15
+
16
+
17
+ def _is_repo(repo: Path) -> bool:
18
+ try:
19
+ r = subprocess.run(
20
+ ["git", "-C", str(repo), "rev-parse", "--git-dir"],
21
+ capture_output=True, text=True, timeout=10,
22
+ )
23
+ return r.returncode == 0
24
+ except Exception: # noqa: BLE001
25
+ return False
26
+
27
+
28
+ def _run_git(repo: Path, args: list[str], timeout_seconds: int = 30) -> dict:
29
+ """Run a git command in repo and return a result dict.
30
+
31
+ Surfaces failures as {"error": ...} instead of raising.
32
+ """
33
+ try:
34
+ r = subprocess.run(
35
+ ["git", *args],
36
+ capture_output=True,
37
+ text=True,
38
+ timeout=timeout_seconds,
39
+ cwd=str(repo),
40
+ )
41
+ except FileNotFoundError:
42
+ return {"error": "git executable not found on PATH"}
43
+ except subprocess.TimeoutExpired:
44
+ return {"error": f"git command timed out after {timeout_seconds}s"}
45
+ except Exception as e: # noqa: BLE001 - surface failure to the model
46
+ return {"error": f"{type(e).__name__}: {e}"}
47
+ stdout = r.stdout or ""
48
+ if len(stdout) > 50000:
49
+ stdout = stdout[:50000] + "\n... (output truncated at 50KB)"
50
+ return {
51
+ "output": stdout.strip(),
52
+ "stderr": (r.stderr or "").strip()[:10000],
53
+ "return_code": r.returncode,
54
+ }
55
+
56
+
57
+ @tool(dangerous=False)
58
+ def git_status(repo_path: str) -> str:
59
+ """Show the working tree status of a git repository.
60
+
61
+ :param repo_path: Path to the git repository.
62
+ """
63
+ repo = _repo(repo_path)
64
+ if not _is_repo(repo):
65
+ return json.dumps({"error": f"Not a git repository: {repo_path}"})
66
+ return json.dumps(_run_git(repo, ["status", "--porcelain=v2", "--branch"]))
67
+
68
+
69
+ @tool(dangerous=False)
70
+ def git_diff(repo_path: str, staged: bool = False) -> str:
71
+ """Show changes in the working directory or staging area.
72
+
73
+ :param repo_path: Path to the git repository.
74
+ :param staged: If true, show staged changes; default shows unstaged changes.
75
+ """
76
+ repo = _repo(repo_path)
77
+ if not _is_repo(repo):
78
+ return json.dumps({"error": f"Not a git repository: {repo_path}"})
79
+ args = ["diff"]
80
+ if staged:
81
+ args.append("--cached")
82
+ return json.dumps(_run_git(repo, args))
83
+
84
+
85
+ @tool(dangerous=False)
86
+ def git_log(repo_path: str, count: int = 10) -> str:
87
+ """Show recent commit history as one line per commit.
88
+
89
+ :param repo_path: Path to the git repository.
90
+ :param count: Number of commits to show (capped at 50).
91
+ """
92
+ repo = _repo(repo_path)
93
+ if not _is_repo(repo):
94
+ return json.dumps({"error": f"Not a git repository: {repo_path}"})
95
+ count = max(1, min(int(count), 50))
96
+ return json.dumps(_run_git(repo, ["log", f"-{count}", "--oneline", "--decorate"]))
97
+
98
+
99
+ @tool(dangerous=False)
100
+ def git_branch(repo_path: str) -> str:
101
+ """List branches and report the current branch.
102
+
103
+ :param repo_path: Path to the git repository.
104
+ """
105
+ repo = _repo(repo_path)
106
+ if not _is_repo(repo):
107
+ return json.dumps({"error": f"Not a git repository: {repo_path}"})
108
+ listing = _run_git(repo, ["branch", "--list"])
109
+ if "error" in listing:
110
+ return json.dumps(listing)
111
+ current = _run_git(repo, ["rev-parse", "--abbrev-ref", "HEAD"])
112
+ branches = []
113
+ for line in listing["output"].splitlines():
114
+ name = line.replace("*", "", 1).strip()
115
+ if name:
116
+ branches.append(name)
117
+ return json.dumps(
118
+ {
119
+ "branches": branches,
120
+ "current": current.get("output", ""),
121
+ "return_code": listing["return_code"],
122
+ }
123
+ )
124
+
125
+
126
+ @tool(dangerous=True)
127
+ def git_add(repo_path: str, paths: str = ".") -> str:
128
+ """Stage files for the next commit.
129
+
130
+ :param repo_path: Path to the git repository.
131
+ :param paths: Space-separated pathspecs to stage (default "." for all).
132
+ """
133
+ repo = _repo(repo_path)
134
+ if not _is_repo(repo):
135
+ return json.dumps({"error": f"Not a git repository: {repo_path}"})
136
+ pathspecs = shlex.split(paths) if paths.strip() else ["."]
137
+ return json.dumps(_run_git(repo, ["add", "--", *pathspecs]))
138
+
139
+
140
+ @tool(dangerous=True)
141
+ def git_commit(repo_path: str, message: str, add_all: bool = False) -> str:
142
+ """Create a commit, optionally staging all tracked changes first.
143
+
144
+ :param repo_path: Path to the git repository.
145
+ :param message: The commit message.
146
+ :param add_all: If true, stage all tracked changes before committing.
147
+ """
148
+ repo = _repo(repo_path)
149
+ if not _is_repo(repo):
150
+ return json.dumps({"error": f"Not a git repository: {repo_path}"})
151
+ args = ["commit", "-m", message]
152
+ if add_all:
153
+ args.append("--all")
154
+ return json.dumps(_run_git(repo, args))
155
+
156
+
157
+ @tool(dangerous=True)
158
+ def git_clone(url: str, target_path: str) -> str:
159
+ """Clone a git repository into a local path.
160
+
161
+ :param url: The repository URL to clone.
162
+ :param target_path: Local path to clone into.
163
+ """
164
+ target = Path(target_path).expanduser().resolve()
165
+ try:
166
+ r = subprocess.run(
167
+ ["git", "clone", url, str(target)],
168
+ capture_output=True,
169
+ text=True,
170
+ timeout=300,
171
+ )
172
+ except FileNotFoundError:
173
+ return json.dumps({"error": "git executable not found on PATH"})
174
+ except subprocess.TimeoutExpired:
175
+ return json.dumps({"error": "git clone timed out after 300s"})
176
+ except Exception as e: # noqa: BLE001 - surface failure to the model
177
+ return json.dumps({"error": f"{type(e).__name__}: {e}"})
178
+ return json.dumps(
179
+ {
180
+ "output": (r.stdout or "").strip(),
181
+ "stderr": (r.stderr or "").strip()[:10000],
182
+ "return_code": r.returncode,
183
+ "target_path": str(target),
184
+ }
185
+ )
@@ -35,8 +35,9 @@ _pool_lock = threading.Lock()
35
35
  class _ServerConnection:
36
36
  """One MCP server subprocess plus its JSON-RPC stdio channel."""
37
37
 
38
- def __init__(self, command: list[str], env: dict | None = None) -> None:
38
+ def __init__(self, command: list[str], env: dict | None = None, cwd: str | None = None) -> None:
39
39
  self.command = command
40
+ self._cwd = cwd
40
41
  self.server_info: dict = {}
41
42
  self.tools: list[dict] = []
42
43
  self.last_used = time.time()
@@ -56,6 +57,7 @@ class _ServerConnection:
56
57
  stdout=subprocess.PIPE,
57
58
  stderr=subprocess.PIPE,
58
59
  env=self._env,
60
+ cwd=self._cwd,
59
61
  )
60
62
  init = self._request(
61
63
  "initialize",
@@ -124,6 +126,62 @@ class _ServerConnection:
124
126
  return json.loads(line.decode())
125
127
 
126
128
 
129
+ _CONFIG_ENV = "DEEPPARALLEL_MCP_CONFIG"
130
+ _DEFAULT_CONFIG = os.path.expanduser("~/.config/deepparallel/mcp.json")
131
+ _registry_cache: dict | None = None
132
+
133
+
134
+ def _load_registry() -> dict:
135
+ """Custom/local MCP servers from the user config (Claude `mcpServers` format).
136
+
137
+ Path: $DEEPPARALLEL_MCP_CONFIG or ~/.config/deepparallel/mcp.json. Each entry
138
+ has command/args/cwd/env (env values expand $VARS from the process env, so
139
+ the model's servers inherit the configured key vault). Cached per process.
140
+ """
141
+ global _registry_cache
142
+ if _registry_cache is not None:
143
+ return _registry_cache
144
+ path = os.environ.get(_CONFIG_ENV) or _DEFAULT_CONFIG
145
+ servers: dict = {}
146
+ try:
147
+ with open(path) as fh:
148
+ data = json.load(fh)
149
+ for name, spec in (data.get("mcpServers") or {}).items():
150
+ cmd = [str(spec["command"]), *[str(a) for a in spec.get("args", [])]]
151
+ env = {k: os.path.expandvars(str(v)) for k, v in (spec.get("env") or {}).items()}
152
+ cwd = spec.get("cwd")
153
+ servers[name] = {
154
+ "command": [os.path.expandvars(c) for c in cmd],
155
+ "cwd": os.path.expandvars(cwd) if cwd else None,
156
+ "env": env,
157
+ "description": str(spec.get("description", "")),
158
+ }
159
+ except (OSError, ValueError, KeyError, TypeError):
160
+ pass
161
+ _registry_cache = servers
162
+ return servers
163
+
164
+
165
+ def _custom_matches(query: str, servers: dict) -> list[dict]:
166
+ """Local servers whose name/description matches the query, as search rows."""
167
+ q = query.lower()
168
+ tokens = [t for t in re.findall(r"[a-z0-9]+", q) if len(t) > 2]
169
+ out = []
170
+ for name, spec in servers.items():
171
+ hay = (name + " " + spec.get("description", "")).lower()
172
+ if not query.strip() or q in hay or any(t in hay for t in tokens):
173
+ out.append(
174
+ {
175
+ "name": name,
176
+ "description": spec.get("description", ""),
177
+ "version": "local",
178
+ "local": True,
179
+ "packages": [{"type": "command", "package": name, "transport": "stdio"}],
180
+ }
181
+ )
182
+ return out
183
+
184
+
127
185
  def _build_command(package: str, package_type: str) -> list[str]:
128
186
  if package_type == "npm":
129
187
  return ["npx", "-y", package]
@@ -146,7 +204,11 @@ def _get_or_start(package: str, package_type: str) -> _ServerConnection:
146
204
  oldest = min(_pool, key=lambda k: _pool[k].last_used)
147
205
  _pool[oldest].stop()
148
206
  del _pool[oldest]
149
- conn = _ServerConnection(_build_command(package, package_type))
207
+ custom = _load_registry().get(package)
208
+ if custom is not None:
209
+ conn = _ServerConnection(custom["command"], env=custom["env"], cwd=custom["cwd"])
210
+ else:
211
+ conn = _ServerConnection(_build_command(package, package_type))
150
212
  conn.start()
151
213
  _pool[package] = conn
152
214
  return conn
@@ -224,6 +286,11 @@ def mcp_search(query: str, limit: int = 10) -> str:
224
286
  except Exception as e: # noqa: BLE001 - surface registry failure to the model
225
287
  return json.dumps({"error": f"registry search failed: {type(e).__name__}: {e}"})
226
288
  results = [_format_server(entry) for entry in servers]
289
+ # Local/custom servers (crios-nova, lab MCPs) come first: they are the
290
+ # user's own, and the public registry never lists them.
291
+ custom = _custom_matches(query, _load_registry())
292
+ seen = {c["name"] for c in custom}
293
+ results = custom + [r for r in results if r["name"] not in seen]
227
294
  return json.dumps({"query": query, "count": len(results), "servers": results})
228
295
 
229
296