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.
- {deepparallel-0.5.5 → deepparallel-0.5.7}/PKG-INFO +1 -1
- {deepparallel-0.5.5 → deepparallel-0.5.7}/deepparallel/__init__.py +1 -1
- {deepparallel-0.5.5 → deepparallel-0.5.7}/deepparallel/branding.py +3 -3
- {deepparallel-0.5.5 → deepparallel-0.5.7}/deepparallel/cli.py +20 -6
- {deepparallel-0.5.5 → deepparallel-0.5.7}/deepparallel/config.py +47 -0
- deepparallel-0.5.7/deepparallel/memory.py +186 -0
- {deepparallel-0.5.5 → deepparallel-0.5.7}/deepparallel/tools/__init__.py +2 -0
- deepparallel-0.5.7/deepparallel/tools/git_ops.py +185 -0
- {deepparallel-0.5.5 → deepparallel-0.5.7}/deepparallel/tools/mcp.py +69 -2
- deepparallel-0.5.7/deepparallel/tools/memory.py +37 -0
- {deepparallel-0.5.5 → deepparallel-0.5.7}/deepparallel.egg-info/PKG-INFO +1 -1
- {deepparallel-0.5.5 → deepparallel-0.5.7}/deepparallel.egg-info/SOURCES.txt +6 -0
- {deepparallel-0.5.5 → deepparallel-0.5.7}/pyproject.toml +1 -1
- deepparallel-0.5.7/tests/test_config_file.py +26 -0
- deepparallel-0.5.7/tests/test_git_ops.py +116 -0
- deepparallel-0.5.7/tests/test_memory.py +68 -0
- {deepparallel-0.5.5 → deepparallel-0.5.7}/tests/test_tools_mcp.py +37 -0
- {deepparallel-0.5.5 → deepparallel-0.5.7}/README.md +0 -0
- {deepparallel-0.5.5 → deepparallel-0.5.7}/deepparallel/agent.py +0 -0
- {deepparallel-0.5.5 → deepparallel-0.5.7}/deepparallel/backend.py +0 -0
- {deepparallel-0.5.5 → deepparallel-0.5.7}/deepparallel/cockpit.py +0 -0
- {deepparallel-0.5.5 → deepparallel-0.5.7}/deepparallel/cockpit_observe.py +0 -0
- {deepparallel-0.5.5 → deepparallel-0.5.7}/deepparallel/cockpit_panel.py +0 -0
- {deepparallel-0.5.5 → deepparallel-0.5.7}/deepparallel/cockpit_sim.py +0 -0
- {deepparallel-0.5.5 → deepparallel-0.5.7}/deepparallel/crowe_id.py +0 -0
- {deepparallel-0.5.5 → deepparallel-0.5.7}/deepparallel/dsml.py +0 -0
- {deepparallel-0.5.5 → deepparallel-0.5.7}/deepparallel/fusion.py +0 -0
- {deepparallel-0.5.5 → deepparallel-0.5.7}/deepparallel/licensing.py +0 -0
- {deepparallel-0.5.5 → deepparallel-0.5.7}/deepparallel/registry.json +0 -0
- {deepparallel-0.5.5 → deepparallel-0.5.7}/deepparallel/renderer.py +0 -0
- {deepparallel-0.5.5 → deepparallel-0.5.7}/deepparallel/research/__init__.py +0 -0
- {deepparallel-0.5.5 → deepparallel-0.5.7}/deepparallel/research/conduit.py +0 -0
- {deepparallel-0.5.5 → deepparallel-0.5.7}/deepparallel/research/provider.py +0 -0
- {deepparallel-0.5.5 → deepparallel-0.5.7}/deepparallel/routing.example.json +0 -0
- {deepparallel-0.5.5 → deepparallel-0.5.7}/deepparallel/routing.py +0 -0
- {deepparallel-0.5.5 → deepparallel-0.5.7}/deepparallel/serve.py +0 -0
- {deepparallel-0.5.5 → deepparallel-0.5.7}/deepparallel/supply_chain.py +0 -0
- {deepparallel-0.5.5 → deepparallel-0.5.7}/deepparallel/system_prompt.txt +0 -0
- {deepparallel-0.5.5 → deepparallel-0.5.7}/deepparallel/tools/codeast.py +0 -0
- {deepparallel-0.5.5 → deepparallel-0.5.7}/deepparallel/tools/edit.py +0 -0
- {deepparallel-0.5.5 → deepparallel-0.5.7}/deepparallel/tools/files.py +0 -0
- {deepparallel-0.5.5 → deepparallel-0.5.7}/deepparallel/tools/registry.py +0 -0
- {deepparallel-0.5.5 → deepparallel-0.5.7}/deepparallel/tools/sandbox.py +0 -0
- {deepparallel-0.5.5 → deepparallel-0.5.7}/deepparallel/tools/search.py +0 -0
- {deepparallel-0.5.5 → deepparallel-0.5.7}/deepparallel/tools/shell.py +0 -0
- {deepparallel-0.5.5 → deepparallel-0.5.7}/deepparallel/tools/vision.py +0 -0
- {deepparallel-0.5.5 → deepparallel-0.5.7}/deepparallel/tools/web.py +0 -0
- {deepparallel-0.5.5 → deepparallel-0.5.7}/deepparallel/userinput.py +0 -0
- {deepparallel-0.5.5 → deepparallel-0.5.7}/deepparallel.egg-info/dependency_links.txt +0 -0
- {deepparallel-0.5.5 → deepparallel-0.5.7}/deepparallel.egg-info/entry_points.txt +0 -0
- {deepparallel-0.5.5 → deepparallel-0.5.7}/deepparallel.egg-info/requires.txt +0 -0
- {deepparallel-0.5.5 → deepparallel-0.5.7}/deepparallel.egg-info/top_level.txt +0 -0
- {deepparallel-0.5.5 → deepparallel-0.5.7}/setup.cfg +0 -0
- {deepparallel-0.5.5 → deepparallel-0.5.7}/tests/test_agent.py +0 -0
- {deepparallel-0.5.5 → deepparallel-0.5.7}/tests/test_backend.py +0 -0
- {deepparallel-0.5.5 → deepparallel-0.5.7}/tests/test_backend_chat.py +0 -0
- {deepparallel-0.5.5 → deepparallel-0.5.7}/tests/test_backend_stream.py +0 -0
- {deepparallel-0.5.5 → deepparallel-0.5.7}/tests/test_branding.py +0 -0
- {deepparallel-0.5.5 → deepparallel-0.5.7}/tests/test_cli.py +0 -0
- {deepparallel-0.5.5 → deepparallel-0.5.7}/tests/test_cockpit.py +0 -0
- {deepparallel-0.5.5 → deepparallel-0.5.7}/tests/test_cockpit_panel.py +0 -0
- {deepparallel-0.5.5 → deepparallel-0.5.7}/tests/test_cockpit_sim.py +0 -0
- {deepparallel-0.5.5 → deepparallel-0.5.7}/tests/test_config.py +0 -0
- {deepparallel-0.5.5 → deepparallel-0.5.7}/tests/test_crowe_backend.py +0 -0
- {deepparallel-0.5.5 → deepparallel-0.5.7}/tests/test_crowe_gateway_backend.py +0 -0
- {deepparallel-0.5.5 → deepparallel-0.5.7}/tests/test_crowe_id_auth.py +0 -0
- {deepparallel-0.5.5 → deepparallel-0.5.7}/tests/test_crowe_payment_required.py +0 -0
- {deepparallel-0.5.5 → deepparallel-0.5.7}/tests/test_dsml.py +0 -0
- {deepparallel-0.5.5 → deepparallel-0.5.7}/tests/test_fusion.py +0 -0
- {deepparallel-0.5.5 → deepparallel-0.5.7}/tests/test_issuer_signer.py +0 -0
- {deepparallel-0.5.5 → deepparallel-0.5.7}/tests/test_licensing.py +0 -0
- {deepparallel-0.5.5 → deepparallel-0.5.7}/tests/test_renderer.py +0 -0
- {deepparallel-0.5.5 → deepparallel-0.5.7}/tests/test_research.py +0 -0
- {deepparallel-0.5.5 → deepparallel-0.5.7}/tests/test_research_provider.py +0 -0
- {deepparallel-0.5.5 → deepparallel-0.5.7}/tests/test_routing.py +0 -0
- {deepparallel-0.5.5 → deepparallel-0.5.7}/tests/test_serve.py +0 -0
- {deepparallel-0.5.5 → deepparallel-0.5.7}/tests/test_spinner_color.py +0 -0
- {deepparallel-0.5.5 → deepparallel-0.5.7}/tests/test_supply_chain.py +0 -0
- {deepparallel-0.5.5 → deepparallel-0.5.7}/tests/test_tool_registry.py +0 -0
- {deepparallel-0.5.5 → deepparallel-0.5.7}/tests/test_tools_codeast.py +0 -0
- {deepparallel-0.5.5 → deepparallel-0.5.7}/tests/test_tools_edit.py +0 -0
- {deepparallel-0.5.5 → deepparallel-0.5.7}/tests/test_tools_files.py +0 -0
- {deepparallel-0.5.5 → deepparallel-0.5.7}/tests/test_tools_sandbox.py +0 -0
- {deepparallel-0.5.5 → deepparallel-0.5.7}/tests/test_tools_search.py +0 -0
- {deepparallel-0.5.5 → deepparallel-0.5.7}/tests/test_tools_shell.py +0 -0
- {deepparallel-0.5.5 → deepparallel-0.5.7}/tests/test_tools_vision.py +0 -0
- {deepparallel-0.5.5 → deepparallel-0.5.7}/tests/test_tools_web.py +0 -0
- {deepparallel-0.5.5 → deepparallel-0.5.7}/tests/test_userinput.py +0 -0
- {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.
|
|
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
|
|
@@ -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+
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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
|
|
@@ -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
|
-
|
|
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
|
|