deepparallel 0.5.4__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.4 → deepparallel-0.5.7}/PKG-INFO +1 -1
  2. {deepparallel-0.5.4 → deepparallel-0.5.7}/deepparallel/__init__.py +1 -1
  3. {deepparallel-0.5.4 → deepparallel-0.5.7}/deepparallel/branding.py +3 -3
  4. {deepparallel-0.5.4 → deepparallel-0.5.7}/deepparallel/cli.py +42 -16
  5. {deepparallel-0.5.4 → deepparallel-0.5.7}/deepparallel/cockpit.py +20 -0
  6. {deepparallel-0.5.4 → deepparallel-0.5.7}/deepparallel/cockpit_observe.py +9 -2
  7. {deepparallel-0.5.4 → deepparallel-0.5.7}/deepparallel/config.py +47 -0
  8. deepparallel-0.5.7/deepparallel/memory.py +186 -0
  9. {deepparallel-0.5.4 → deepparallel-0.5.7}/deepparallel/tools/__init__.py +2 -0
  10. deepparallel-0.5.7/deepparallel/tools/git_ops.py +185 -0
  11. {deepparallel-0.5.4 → deepparallel-0.5.7}/deepparallel/tools/mcp.py +69 -2
  12. deepparallel-0.5.7/deepparallel/tools/memory.py +37 -0
  13. {deepparallel-0.5.4 → deepparallel-0.5.7}/deepparallel.egg-info/PKG-INFO +1 -1
  14. {deepparallel-0.5.4 → deepparallel-0.5.7}/deepparallel.egg-info/SOURCES.txt +6 -0
  15. {deepparallel-0.5.4 → deepparallel-0.5.7}/pyproject.toml +1 -1
  16. {deepparallel-0.5.4 → deepparallel-0.5.7}/tests/test_cockpit.py +41 -0
  17. deepparallel-0.5.7/tests/test_config_file.py +26 -0
  18. deepparallel-0.5.7/tests/test_git_ops.py +116 -0
  19. deepparallel-0.5.7/tests/test_memory.py +68 -0
  20. {deepparallel-0.5.4 → deepparallel-0.5.7}/tests/test_tools_mcp.py +37 -0
  21. {deepparallel-0.5.4 → deepparallel-0.5.7}/README.md +0 -0
  22. {deepparallel-0.5.4 → deepparallel-0.5.7}/deepparallel/agent.py +0 -0
  23. {deepparallel-0.5.4 → deepparallel-0.5.7}/deepparallel/backend.py +0 -0
  24. {deepparallel-0.5.4 → deepparallel-0.5.7}/deepparallel/cockpit_panel.py +0 -0
  25. {deepparallel-0.5.4 → deepparallel-0.5.7}/deepparallel/cockpit_sim.py +0 -0
  26. {deepparallel-0.5.4 → deepparallel-0.5.7}/deepparallel/crowe_id.py +0 -0
  27. {deepparallel-0.5.4 → deepparallel-0.5.7}/deepparallel/dsml.py +0 -0
  28. {deepparallel-0.5.4 → deepparallel-0.5.7}/deepparallel/fusion.py +0 -0
  29. {deepparallel-0.5.4 → deepparallel-0.5.7}/deepparallel/licensing.py +0 -0
  30. {deepparallel-0.5.4 → deepparallel-0.5.7}/deepparallel/registry.json +0 -0
  31. {deepparallel-0.5.4 → deepparallel-0.5.7}/deepparallel/renderer.py +0 -0
  32. {deepparallel-0.5.4 → deepparallel-0.5.7}/deepparallel/research/__init__.py +0 -0
  33. {deepparallel-0.5.4 → deepparallel-0.5.7}/deepparallel/research/conduit.py +0 -0
  34. {deepparallel-0.5.4 → deepparallel-0.5.7}/deepparallel/research/provider.py +0 -0
  35. {deepparallel-0.5.4 → deepparallel-0.5.7}/deepparallel/routing.example.json +0 -0
  36. {deepparallel-0.5.4 → deepparallel-0.5.7}/deepparallel/routing.py +0 -0
  37. {deepparallel-0.5.4 → deepparallel-0.5.7}/deepparallel/serve.py +0 -0
  38. {deepparallel-0.5.4 → deepparallel-0.5.7}/deepparallel/supply_chain.py +0 -0
  39. {deepparallel-0.5.4 → deepparallel-0.5.7}/deepparallel/system_prompt.txt +0 -0
  40. {deepparallel-0.5.4 → deepparallel-0.5.7}/deepparallel/tools/codeast.py +0 -0
  41. {deepparallel-0.5.4 → deepparallel-0.5.7}/deepparallel/tools/edit.py +0 -0
  42. {deepparallel-0.5.4 → deepparallel-0.5.7}/deepparallel/tools/files.py +0 -0
  43. {deepparallel-0.5.4 → deepparallel-0.5.7}/deepparallel/tools/registry.py +0 -0
  44. {deepparallel-0.5.4 → deepparallel-0.5.7}/deepparallel/tools/sandbox.py +0 -0
  45. {deepparallel-0.5.4 → deepparallel-0.5.7}/deepparallel/tools/search.py +0 -0
  46. {deepparallel-0.5.4 → deepparallel-0.5.7}/deepparallel/tools/shell.py +0 -0
  47. {deepparallel-0.5.4 → deepparallel-0.5.7}/deepparallel/tools/vision.py +0 -0
  48. {deepparallel-0.5.4 → deepparallel-0.5.7}/deepparallel/tools/web.py +0 -0
  49. {deepparallel-0.5.4 → deepparallel-0.5.7}/deepparallel/userinput.py +0 -0
  50. {deepparallel-0.5.4 → deepparallel-0.5.7}/deepparallel.egg-info/dependency_links.txt +0 -0
  51. {deepparallel-0.5.4 → deepparallel-0.5.7}/deepparallel.egg-info/entry_points.txt +0 -0
  52. {deepparallel-0.5.4 → deepparallel-0.5.7}/deepparallel.egg-info/requires.txt +0 -0
  53. {deepparallel-0.5.4 → deepparallel-0.5.7}/deepparallel.egg-info/top_level.txt +0 -0
  54. {deepparallel-0.5.4 → deepparallel-0.5.7}/setup.cfg +0 -0
  55. {deepparallel-0.5.4 → deepparallel-0.5.7}/tests/test_agent.py +0 -0
  56. {deepparallel-0.5.4 → deepparallel-0.5.7}/tests/test_backend.py +0 -0
  57. {deepparallel-0.5.4 → deepparallel-0.5.7}/tests/test_backend_chat.py +0 -0
  58. {deepparallel-0.5.4 → deepparallel-0.5.7}/tests/test_backend_stream.py +0 -0
  59. {deepparallel-0.5.4 → deepparallel-0.5.7}/tests/test_branding.py +0 -0
  60. {deepparallel-0.5.4 → deepparallel-0.5.7}/tests/test_cli.py +0 -0
  61. {deepparallel-0.5.4 → deepparallel-0.5.7}/tests/test_cockpit_panel.py +0 -0
  62. {deepparallel-0.5.4 → deepparallel-0.5.7}/tests/test_cockpit_sim.py +0 -0
  63. {deepparallel-0.5.4 → deepparallel-0.5.7}/tests/test_config.py +0 -0
  64. {deepparallel-0.5.4 → deepparallel-0.5.7}/tests/test_crowe_backend.py +0 -0
  65. {deepparallel-0.5.4 → deepparallel-0.5.7}/tests/test_crowe_gateway_backend.py +0 -0
  66. {deepparallel-0.5.4 → deepparallel-0.5.7}/tests/test_crowe_id_auth.py +0 -0
  67. {deepparallel-0.5.4 → deepparallel-0.5.7}/tests/test_crowe_payment_required.py +0 -0
  68. {deepparallel-0.5.4 → deepparallel-0.5.7}/tests/test_dsml.py +0 -0
  69. {deepparallel-0.5.4 → deepparallel-0.5.7}/tests/test_fusion.py +0 -0
  70. {deepparallel-0.5.4 → deepparallel-0.5.7}/tests/test_issuer_signer.py +0 -0
  71. {deepparallel-0.5.4 → deepparallel-0.5.7}/tests/test_licensing.py +0 -0
  72. {deepparallel-0.5.4 → deepparallel-0.5.7}/tests/test_renderer.py +0 -0
  73. {deepparallel-0.5.4 → deepparallel-0.5.7}/tests/test_research.py +0 -0
  74. {deepparallel-0.5.4 → deepparallel-0.5.7}/tests/test_research_provider.py +0 -0
  75. {deepparallel-0.5.4 → deepparallel-0.5.7}/tests/test_routing.py +0 -0
  76. {deepparallel-0.5.4 → deepparallel-0.5.7}/tests/test_serve.py +0 -0
  77. {deepparallel-0.5.4 → deepparallel-0.5.7}/tests/test_spinner_color.py +0 -0
  78. {deepparallel-0.5.4 → deepparallel-0.5.7}/tests/test_supply_chain.py +0 -0
  79. {deepparallel-0.5.4 → deepparallel-0.5.7}/tests/test_tool_registry.py +0 -0
  80. {deepparallel-0.5.4 → deepparallel-0.5.7}/tests/test_tools_codeast.py +0 -0
  81. {deepparallel-0.5.4 → deepparallel-0.5.7}/tests/test_tools_edit.py +0 -0
  82. {deepparallel-0.5.4 → deepparallel-0.5.7}/tests/test_tools_files.py +0 -0
  83. {deepparallel-0.5.4 → deepparallel-0.5.7}/tests/test_tools_sandbox.py +0 -0
  84. {deepparallel-0.5.4 → deepparallel-0.5.7}/tests/test_tools_search.py +0 -0
  85. {deepparallel-0.5.4 → deepparallel-0.5.7}/tests/test_tools_shell.py +0 -0
  86. {deepparallel-0.5.4 → deepparallel-0.5.7}/tests/test_tools_vision.py +0 -0
  87. {deepparallel-0.5.4 → deepparallel-0.5.7}/tests/test_tools_web.py +0 -0
  88. {deepparallel-0.5.4 → deepparallel-0.5.7}/tests/test_userinput.py +0 -0
  89. {deepparallel-0.5.4 → 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.4
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.4"
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
 
@@ -17,7 +17,6 @@ apply to chat and run.
17
17
  from __future__ import annotations
18
18
 
19
19
  import os
20
- import subprocess
21
20
  import sys
22
21
  from dataclasses import replace
23
22
  from pathlib import Path
@@ -46,7 +45,7 @@ from deepparallel.config import (
46
45
  missing_required,
47
46
  resolve_settings,
48
47
  )
49
- from deepparallel import licensing, supply_chain
48
+ from deepparallel import licensing, memory, supply_chain
50
49
  from deepparallel.fusion import (
51
50
  EscalationBackend,
52
51
  ReasonAnswerBackend,
@@ -178,6 +177,11 @@ def _build_guardian(settings: Settings) -> Backend | None:
178
177
  return backend_for_deployment(settings, settings.guardian_deployment)
179
178
 
180
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
+
181
185
  def _make_renderer(*, force_plain: bool, assume_yes: bool = False) -> Renderer:
182
186
  plain = force_plain or _bool_env("DEEPPARALLEL_PLAIN", False) or not sys.stdout.isatty()
183
187
  if plain:
@@ -234,7 +238,7 @@ def _stream_once(backend: Backend, settings: Settings, messages: list[dict]) ->
234
238
 
235
239
  def _stream_repl(backend: Backend, settings: Settings) -> None:
236
240
  """Plain-chat interactive loop (no tools)."""
237
- system = load_system_prompt()
241
+ system = _system_prompt()
238
242
  history: list[tuple[str, str]] = []
239
243
  while True:
240
244
  try:
@@ -290,11 +294,18 @@ def _agent_repl(backend: Backend, settings: Settings, renderer: Renderer) -> Non
290
294
  (/fast //fuse //escalate //deep) swaps the active fusion mode live."""
291
295
  registry = get_registry()
292
296
  guardian = _build_guardian(settings)
293
- system = load_system_prompt()
297
+ system = _system_prompt()
294
298
  messages: list[dict] = [{"role": "system", "content": system}]
295
299
  mode = settings.fusion_mode if settings.fusion_mode in ("reason", "escalate") else "off"
296
300
  deep_next = False
297
301
  auto = settings.auto_approve
302
+ cockpit = Cockpit(os.path.abspath(os.path.join(".cockpit", "events.jsonl")))
303
+ cockpit_on = cockpit.in_waveterm()
304
+ observer = make_observer(cockpit)
305
+ if cockpit_on:
306
+ cockpit.events_path.parent.mkdir(parents=True, exist_ok=True)
307
+ cockpit.events_path.write_text("")
308
+ cockpit.status("start", "cockpit ready")
298
309
  while True:
299
310
  bits = ([mode] if mode != "off" else []) + (["auto"] if auto else [])
300
311
  tag = f"[{' · '.join(bits)}] " if bits else ""
@@ -309,7 +320,7 @@ def _agent_repl(backend: Backend, settings: Settings, renderer: Renderer) -> Non
309
320
  break
310
321
  if user_msg == "/help":
311
322
  branding.info(
312
- "/quit · /reset · /info · /tools · /auto · /fast //fuse //escalate //deep · prompt"
323
+ "/quit · /reset · /info · /tools · /auto · /memory · /cockpit · /fast //fuse //escalate //deep · prompt"
313
324
  )
314
325
  continue
315
326
  if user_msg in {"/auto", "/yes"}:
@@ -337,6 +348,26 @@ def _agent_repl(backend: Backend, settings: Settings, renderer: Renderer) -> Non
337
348
  deep_next = True
338
349
  branding.info("next prompt runs as a multi-model deep query")
339
350
  continue
351
+ if user_msg == "/cockpit":
352
+ cockpit_on = not cockpit_on
353
+ if cockpit_on:
354
+ cockpit.events_path.parent.mkdir(parents=True, exist_ok=True)
355
+ cockpit.events_path.write_text("")
356
+ cockpit._panel_opened = False
357
+ branding.info("cockpit ON - the data window opens when research starts.")
358
+ else:
359
+ cockpit.status("done", "cockpit complete")
360
+ branding.info("cockpit OFF.")
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
340
371
 
341
372
  messages.append({"role": "user", "content": user_msg})
342
373
  if deep_next:
@@ -354,12 +385,15 @@ def _agent_repl(backend: Backend, settings: Settings, renderer: Renderer) -> Non
354
385
  auto_approve=auto,
355
386
  stream=True,
356
387
  guardian=guardian,
388
+ on_event=observer if cockpit_on else None,
357
389
  )
358
390
  if mode in ("reason", "escalate"):
359
391
  branding.info("reasoned by CroweLM Reason · answered by DeepParallel")
360
392
  except Exception as e: # noqa: BLE001 - surface as friendly message
361
393
  renderer.error(_translate_error(e))
362
394
 
395
+ if cockpit_on:
396
+ cockpit.status("done", "cockpit complete")
363
397
  branding.attribution_footer()
364
398
 
365
399
 
@@ -462,7 +496,7 @@ def run(
462
496
  if fuse is not None:
463
497
  settings = replace(settings, fusion_mode=fuse)
464
498
  backend = _require_ready(settings)
465
- system = load_system_prompt()
499
+ system = _system_prompt()
466
500
  messages = _build_messages([], system, " ".join(prompt))
467
501
  if deep:
468
502
  _run_deep(settings, messages)
@@ -508,17 +542,9 @@ def cockpit(ctx: click.Context, assume_yes: bool, prompt: tuple[str, ...]) -> No
508
542
  open(events_path, "w").close()
509
543
  cp = Cockpit(events_path)
510
544
  cp.status("start", "cockpit online")
511
- if cp.in_waveterm():
512
- try:
513
- subprocess.Popen(
514
- ["wsh", "run", "--", "dp", "_cockpit-panel", events_path],
515
- stdout=subprocess.DEVNULL,
516
- stderr=subprocess.DEVNULL,
517
- )
518
- except Exception: # noqa: BLE001 - the panel block is best-effort
519
- pass
545
+ cp.ensure_panel()
520
546
  backend = _wrap_fusion(backend, settings)
521
- system = load_system_prompt()
547
+ system = _system_prompt()
522
548
  messages = _build_messages([], system, " ".join(prompt))
523
549
  renderer = _make_renderer(force_plain=False, assume_yes=settings.auto_approve)
524
550
  try:
@@ -45,6 +45,7 @@ class Cockpit:
45
45
 
46
46
  def __init__(self, events_path: str = ".cockpit/events.jsonl"):
47
47
  self.events_path = Path(events_path)
48
+ self._panel_opened = False
48
49
 
49
50
  # --- environment -------------------------------------------------------
50
51
 
@@ -119,6 +120,25 @@ class Cockpit:
119
120
  """Best-effort `wsh notify`. Silent no-op off WaveTerm or on failure."""
120
121
  self._run_wsh(["notify", message])
121
122
 
123
+ def ensure_panel(self) -> None:
124
+ """Open the live data-window block once per session (idempotent).
125
+
126
+ Spawns `dp _cockpit-panel <abs-path>` in an adjacent WaveTerm block on
127
+ the first call so the cockpit appears only when research actually
128
+ starts; repeat calls and runs outside WaveTerm are no-ops.
129
+ """
130
+ if self._panel_opened or not self.in_waveterm():
131
+ return
132
+ self._panel_opened = True
133
+ try:
134
+ subprocess.Popen(
135
+ ["wsh", "run", "--", "dp", "_cockpit-panel", str(self.events_path.resolve())],
136
+ stdout=subprocess.DEVNULL,
137
+ stderr=subprocess.DEVNULL,
138
+ )
139
+ except Exception: # noqa: BLE001 - the panel block is best-effort
140
+ pass
141
+
122
142
  # --- contract event helpers -------------------------------------------
123
143
 
124
144
  def status(self, phase: str, message: str) -> None:
@@ -31,8 +31,11 @@ def make_observer(cockpit) -> Callable[[str, dict, str], None]:
31
31
  try:
32
32
  if isinstance(obj, dict) and "error" not in obj:
33
33
  if name == "web_search":
34
+ results = obj.get("results") or []
35
+ if results:
36
+ cockpit.ensure_panel()
34
37
  provider = obj.get("provider", "web")
35
- for r in (obj.get("results") or [])[:8]:
38
+ for r in results[:8]:
36
39
  url = r.get("url")
37
40
  if not url:
38
41
  continue
@@ -45,11 +48,15 @@ def make_observer(cockpit) -> Callable[[str, dict, str], None]:
45
48
  elif name == "web_fetch":
46
49
  url = obj.get("url") or (args or {}).get("url", "")
47
50
  if url:
51
+ cockpit.ensure_panel()
48
52
  cockpit.source(url=url, title=obj.get("title", ""), provider="fetch")
49
53
  if counters["blocks"] < _MAX_WEB_BLOCKS and cockpit.open_web(url):
50
54
  counters["blocks"] += 1
51
55
  elif name == "mcp_search":
52
- for s in (obj.get("servers") or [])[:5]:
56
+ servers = obj.get("servers") or []
57
+ if servers:
58
+ cockpit.ensure_panel()
59
+ for s in servers[:5]:
53
60
  cockpit.source(url="", title=s.get("name", ""), provider="mcp")
54
61
  cockpit.status("tool", name)
55
62
  except Exception: # noqa: BLE001 - observation must never break the loop
@@ -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
+ )