vimlm 0.0.4__py3-none-any.whl → 0.0.6__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- vimlm-0.0.6.dist-info/METADATA +158 -0
- vimlm-0.0.6.dist-info/RECORD +7 -0
- vimlm.py +355 -159
- vimlm-0.0.4.dist-info/METADATA +0 -82
- vimlm-0.0.4.dist-info/RECORD +0 -7
- {vimlm-0.0.4.dist-info → vimlm-0.0.6.dist-info}/LICENSE +0 -0
- {vimlm-0.0.4.dist-info → vimlm-0.0.6.dist-info}/WHEEL +0 -0
- {vimlm-0.0.4.dist-info → vimlm-0.0.6.dist-info}/entry_points.txt +0 -0
- {vimlm-0.0.4.dist-info → vimlm-0.0.6.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,158 @@
|
|
1
|
+
Metadata-Version: 2.2
|
2
|
+
Name: vimlm
|
3
|
+
Version: 0.0.6
|
4
|
+
Summary: VimLM - LLM-powered Vim assistant
|
5
|
+
Home-page: https://github.com/JosefAlbers/vimlm
|
6
|
+
Author: Josef Albers
|
7
|
+
Author-email: albersj66@gmail.com
|
8
|
+
Requires-Python: >=3.12.8
|
9
|
+
Description-Content-Type: text/markdown
|
10
|
+
License-File: LICENSE
|
11
|
+
Requires-Dist: nanollama==0.0.5
|
12
|
+
Requires-Dist: mlx_lm_utils==0.0.2
|
13
|
+
Requires-Dist: watchfiles==1.0.4
|
14
|
+
Dynamic: author
|
15
|
+
Dynamic: author-email
|
16
|
+
Dynamic: description
|
17
|
+
Dynamic: description-content-type
|
18
|
+
Dynamic: home-page
|
19
|
+
Dynamic: requires-dist
|
20
|
+
Dynamic: requires-python
|
21
|
+
Dynamic: summary
|
22
|
+
|
23
|
+
|
24
|
+
# VimLM - Local LLM-Powered Coding Assistant for Vim
|
25
|
+
|
26
|
+

|
27
|
+
|
28
|
+
LLM-powered coding companion for Vim, inspired by GitHub Copilot/Cursor. Integrates contextual code understanding, summarization, and AI assistance directly into your Vim workflow.
|
29
|
+
|
30
|
+
## Features
|
31
|
+
|
32
|
+
- **Model Agnostic** - Use any MLX-compatible model via a configuration file
|
33
|
+
- **Vim-Native UX** - Intuitive keybindings and split-window responses
|
34
|
+
- **Deep Context** - Understands code context from:
|
35
|
+
- Current file
|
36
|
+
- Visual selections
|
37
|
+
- Referenced files
|
38
|
+
- Project directory structure
|
39
|
+
- **Conversational Coding** - Iterative refinement with follow-up queries
|
40
|
+
- **Air-Gapped Security** - 100% offline - no APIs, no tracking, no data leaks
|
41
|
+
|
42
|
+
## Requirements
|
43
|
+
|
44
|
+
- Apple M-series chip
|
45
|
+
- Python 3.12.8
|
46
|
+
|
47
|
+
## Quick Start
|
48
|
+
|
49
|
+
Install:
|
50
|
+
|
51
|
+
```zsh
|
52
|
+
pip install vimlm
|
53
|
+
```
|
54
|
+
|
55
|
+
Launch:
|
56
|
+
|
57
|
+
```zsh
|
58
|
+
vimlm
|
59
|
+
```
|
60
|
+
|
61
|
+
or
|
62
|
+
|
63
|
+
```zsh
|
64
|
+
vimlm path/to/your_file
|
65
|
+
```
|
66
|
+
|
67
|
+
This launches Vim with the LLM in a split window, ready to assist you.
|
68
|
+
|
69
|
+
## Basic Usage
|
70
|
+
|
71
|
+
1. **From Normal Mode**:
|
72
|
+
- `Ctrl-l`: Adds current line + file to context
|
73
|
+
- Example prompt: "Regex for removing html tags from item.content"
|
74
|
+
|
75
|
+
2. **From Visual Mode**:
|
76
|
+
- Select code → `Ctrl-l`: Adds selected block + current file to context
|
77
|
+
- Example prompt: "Convert this to async/await syntax"
|
78
|
+
|
79
|
+
3. **Inline Commands**:
|
80
|
+
|
81
|
+
!include: Adds specified outside files/folders to context:
|
82
|
+
- `!include` (no path): Current folder
|
83
|
+
- `!include ~/scrap/jph00/hypermedia-applications.summ.md`: Specific folder
|
84
|
+
- `!include ~/wtm/utils.py`: Specific file
|
85
|
+
- Example prompt: "AJAX-ify this app @ ~/scrap/jph00/hypermedia-applications.summ.md"
|
86
|
+
|
87
|
+
!deploy: Extract code blocks to files in user specified dir (current dir if none specified).
|
88
|
+
|
89
|
+
!continue: Lets the LLM resume the generation from where it had halted due to length limits.
|
90
|
+
|
91
|
+
!followup: Continue the thread (equivalent to `Ctrl-j`
|
92
|
+
|
93
|
+
4. **Follow-Up**: After initial response:
|
94
|
+
- `Ctrl-j`: Continue thread
|
95
|
+
- Example follow-up: "In Manifest V3"
|
96
|
+
|
97
|
+
4. **Code Extraction: Press `Ctrl-p` to choose a code block from the response and insert them into:
|
98
|
+
- The last selected visual block (in Normal mode)
|
99
|
+
- The current selection (in Visual mode)
|
100
|
+
- Example workflow:
|
101
|
+
1. Select a block of code in Visual mode.
|
102
|
+
2. Prompt the LLM with `Ctrl-l` (e.g., "Convert this to async/await syntax").
|
103
|
+
3. Once the response is generated, press `Ctrl-p` to replace the selected block with the extracted code.
|
104
|
+
|
105
|
+
### Key Bindings
|
106
|
+
|
107
|
+
| Binding | Mode | Action |
|
108
|
+
|------------|---------------|----------------------------------------|
|
109
|
+
| `Ctrl-l` | Normal/Visual | Send current file + selection to LLM |
|
110
|
+
| `Ctrl-j` | Normal | Continue conversation |
|
111
|
+
| `Ctrl-p` | Normal/Visual | Replace the selection with generated code |
|
112
|
+
| `Esc` | Prompt | Cancel input |
|
113
|
+
|
114
|
+
## Advanced Configuration
|
115
|
+
|
116
|
+
VimLM uses a JSON config file with the following configurable parameters:
|
117
|
+
|
118
|
+
```json
|
119
|
+
{
|
120
|
+
"DEBUG": true,
|
121
|
+
"LLM_MODEL": null,
|
122
|
+
"NUM_TOKEN": 2000,
|
123
|
+
"SEP_CMD": "!",
|
124
|
+
"USE_LEADER": false
|
125
|
+
}
|
126
|
+
```
|
127
|
+
|
128
|
+
### Custom Model Setup
|
129
|
+
|
130
|
+
1. **Browse models**: [MLX Community Models on Hugging Face](https://huggingface.co/mlx-community)
|
131
|
+
|
132
|
+
2. **Edit config file**:
|
133
|
+
|
134
|
+
```json
|
135
|
+
{
|
136
|
+
"LLM_MODEL": "mlx-community/DeepSeek-R1-Distill-Qwen-7B-4bit"
|
137
|
+
}
|
138
|
+
```
|
139
|
+
3. **Save to**:
|
140
|
+
|
141
|
+
```
|
142
|
+
~/vimlm/config.json
|
143
|
+
```
|
144
|
+
|
145
|
+
4. **Restart VimLM**
|
146
|
+
|
147
|
+
|
148
|
+
### Custom Keybinding
|
149
|
+
|
150
|
+
If you prefer using `<Leader>` in place of `<Ctrl>` for the ViMLM key bindings:
|
151
|
+
|
152
|
+
```json
|
153
|
+
{
|
154
|
+
"USER_LEADER": true
|
155
|
+
}
|
156
|
+
```
|
157
|
+
|
158
|
+
|
@@ -0,0 +1,7 @@
|
|
1
|
+
vimlm.py,sha256=_2EKVmfvM-Ikf48XzaxvC0rcLCxiHO4f6_fCslAqyCo,21181
|
2
|
+
vimlm-0.0.6.dist-info/LICENSE,sha256=f1xgK8fAXg_intwnbc9nLkHf7ODPLtgpHs7DetQHOro,11343
|
3
|
+
vimlm-0.0.6.dist-info/METADATA,sha256=INHaQDN1Id04li0LNWmlkVCIEmnKi9ABpbhAYFGokYE,4233
|
4
|
+
vimlm-0.0.6.dist-info/WHEEL,sha256=In9FTNxeP60KnTkGw7wk6mJPYd_dQSjEZmXdBdMCI-8,91
|
5
|
+
vimlm-0.0.6.dist-info/entry_points.txt,sha256=mU5V4MYsuIzCc6YB-Ro-6USSHWN5vHw8UDnTEoq0isw,36
|
6
|
+
vimlm-0.0.6.dist-info/top_level.txt,sha256=I8GjqoiP--scYsO3AfLhha-6Ax9ci3IvbWvVbPv8g94,6
|
7
|
+
vimlm-0.0.6.dist-info/RECORD,,
|
vimlm.py
CHANGED
@@ -17,7 +17,6 @@ import subprocess
|
|
17
17
|
import json
|
18
18
|
import os
|
19
19
|
from watchfiles import awatch
|
20
|
-
from nanollama32 import Chat
|
21
20
|
import shutil
|
22
21
|
import time
|
23
22
|
from itertools import accumulate
|
@@ -25,13 +24,18 @@ import argparse
|
|
25
24
|
import tempfile
|
26
25
|
from pathlib import Path
|
27
26
|
from string import Template
|
27
|
+
import re
|
28
28
|
|
29
29
|
DEBUG = True
|
30
|
+
LLM_MODEL = None # "mlx-community/DeepSeek-R1-Distill-Qwen-7B-4bit"
|
30
31
|
NUM_TOKEN = 2000
|
31
|
-
SEP_CMD = '
|
32
|
+
SEP_CMD = '!'
|
33
|
+
USE_LEADER = False
|
34
|
+
DO_RESET = True
|
35
|
+
SHOW_USER = False
|
32
36
|
VIMLM_DIR = os.path.expanduser("~/vimlm")
|
33
37
|
WATCH_DIR = os.path.expanduser("~/vimlm/watch_dir")
|
34
|
-
CFG_FILE =
|
38
|
+
CFG_FILE = 'cfg.json'
|
35
39
|
LOG_FILE = "log.json"
|
36
40
|
LTM_FILE = "cache.json"
|
37
41
|
OUT_FILE = "response.md"
|
@@ -41,40 +45,85 @@ LOG_PATH = os.path.join(VIMLM_DIR, LOG_FILE)
|
|
41
45
|
LTM_PATH = os.path.join(VIMLM_DIR, LTM_FILE)
|
42
46
|
OUT_PATH = os.path.join(WATCH_DIR, OUT_FILE)
|
43
47
|
|
44
|
-
|
45
|
-
|
46
|
-
|
47
|
-
|
48
|
-
try:
|
49
|
-
with open(CFG_PATH, "r") as f:
|
50
|
-
cfg = cfg.load(f)
|
51
|
-
DEBUG = config.get("DEBUG", DEBUG)
|
52
|
-
NUM_TOKEN = config.get("NUM_TOKEN", NUM_TOKEN)
|
53
|
-
SEP_CMD = config.get("SEP_CMD", SEP_CMD)
|
54
|
-
except:
|
55
|
-
with open(CFG_PATH, 'w') as f:
|
56
|
-
json.dump(dict(DEBUG=DEBUG, NUM_TOKEN=NUM_TOKEN, SEP_CMD=SEP_CMD), f, indent=2)
|
57
|
-
|
58
|
-
def toout(s, key='tovim'):
|
59
|
-
with open(OUT_PATH, 'w', encoding='utf-8') as f:
|
48
|
+
def toout(s, key=None, mode=None):
|
49
|
+
key = '' if key is None else ':'+key
|
50
|
+
mode = 'w' if mode is None else mode
|
51
|
+
with open(OUT_PATH, mode, encoding='utf-8') as f:
|
60
52
|
f.write(s)
|
61
|
-
tolog(s, key)
|
53
|
+
tolog(s, key='tovim'+key+':'+mode)
|
62
54
|
|
63
55
|
def tolog(log, key='debug'):
|
64
56
|
if not DEBUG and key == 'debug':
|
65
57
|
return
|
66
|
-
|
58
|
+
try:
|
67
59
|
with open(LOG_PATH, "r", encoding="utf-8") as log_f:
|
68
60
|
logs = json.load(log_f)
|
69
|
-
|
61
|
+
except:
|
70
62
|
logs = []
|
71
63
|
logs.append(dict(key=key, log=log, timestamp=time.ctime()))
|
72
64
|
with open(LOG_PATH, "w", encoding="utf-8") as log_f:
|
73
65
|
json.dump(logs, log_f, indent=2)
|
74
66
|
|
67
|
+
if os.path.exists(WATCH_DIR):
|
68
|
+
shutil.rmtree(WATCH_DIR)
|
69
|
+
os.makedirs(WATCH_DIR)
|
70
|
+
|
71
|
+
try:
|
72
|
+
with open(CFG_PATH, "r") as f:
|
73
|
+
config = json.load(f)
|
74
|
+
DEBUG = config.get("DEBUG", DEBUG)
|
75
|
+
LLM_MODEL = config.get("LLM_MODEL", LLM_MODEL)
|
76
|
+
NUM_TOKEN = config.get("NUM_TOKEN", NUM_TOKEN)
|
77
|
+
SEP_CMD = config.get("SEP_CMD", SEP_CMD)
|
78
|
+
USE_LEADER = config.get("USE_LEADER", USE_LEADER)
|
79
|
+
except Exception as e:
|
80
|
+
tolog(str(e))
|
81
|
+
with open(CFG_PATH, 'w') as f:
|
82
|
+
json.dump(dict(DEBUG=DEBUG, LLM_MODEL=LLM_MODEL, NUM_TOKEN=NUM_TOKEN, SEP_CMD=SEP_CMD, USE_LEADER=USE_LEADER), f, indent=2)
|
83
|
+
|
75
84
|
toout('Loading LLM...')
|
76
|
-
|
77
|
-
|
85
|
+
if LLM_MODEL is None:
|
86
|
+
from nanollama import Chat
|
87
|
+
chat = Chat(model_path='uncn_llama_32_3b_it')
|
88
|
+
toout(f'LLM is ready')
|
89
|
+
else:
|
90
|
+
from mlx_lm_utils import Chat
|
91
|
+
chat = Chat(model_path=LLM_MODEL)
|
92
|
+
toout(f'{model_path.split('/')[-1]} is ready')
|
93
|
+
|
94
|
+
def deploy(dest=None, src=None, reformat=True):
|
95
|
+
prompt_deploy = 'Reformat the text to ensure each code block is preceded by a filename in **filename.ext** format, with only alphanumeric characters, dots, underscores, or hyphens in the filename. Remove any extraneous characters from filenames.'
|
96
|
+
tolog(f'deploy {dest=} {src=} {reformat=}')
|
97
|
+
if src:
|
98
|
+
chat.reset()
|
99
|
+
with open(src, 'r') as f:
|
100
|
+
prompt_deploy = f.read().strip() + '\n\n---\n\n' + prompt_deploy
|
101
|
+
if reformat:
|
102
|
+
response = chat(prompt_deploy, max_new=NUM_TOKEN, verbose=False, stream=False)['text']
|
103
|
+
toout(response, 'deploy')
|
104
|
+
lines = response.splitlines()
|
105
|
+
else:
|
106
|
+
with open(OUT_PATH, 'r') as f:
|
107
|
+
lines = f.readlines()
|
108
|
+
dest = get_path(dest)
|
109
|
+
os.makedirs(dest, exist_ok=True)
|
110
|
+
current_filename = None
|
111
|
+
code_block = []
|
112
|
+
in_code_block = False
|
113
|
+
for line in lines:
|
114
|
+
line = line.rstrip()
|
115
|
+
if line.startswith("```"):
|
116
|
+
if in_code_block and current_filename:
|
117
|
+
with open(os.path.join(dest, os.path.basename(current_filename)), "w", encoding="utf-8") as code_file:
|
118
|
+
code_file.write("\n".join(code_block) + "\n")
|
119
|
+
code_block = []
|
120
|
+
in_code_block = not in_code_block
|
121
|
+
elif in_code_block:
|
122
|
+
code_block.append(line)
|
123
|
+
else:
|
124
|
+
match = re.match(r"^\*\*(.+?)\*\*$", line)
|
125
|
+
if match:
|
126
|
+
current_filename = re.sub(r"[^a-zA-Z0-9_.-]", "", match.group(1))
|
78
127
|
|
79
128
|
def is_binary(file_path):
|
80
129
|
try:
|
@@ -115,7 +164,7 @@ def split_str(doc, max_len=2000, get_len=len):
|
|
115
164
|
return chunks
|
116
165
|
|
117
166
|
def retrieve(src_path, max_len=2000, get_len=len):
|
118
|
-
src_path =
|
167
|
+
src_path = get_path(src_path)
|
119
168
|
result = {}
|
120
169
|
if not os.path.exists(src_path):
|
121
170
|
tolog(f"The path {src_path} does not exist.", 'retrieve')
|
@@ -126,26 +175,31 @@ def retrieve(src_path, max_len=2000, get_len=len):
|
|
126
175
|
content = f.read()
|
127
176
|
result = {src_path:dict(timestamp=os.path.getmtime(src_path), list_str=split_str(content, max_len=max_len, get_len=get_len))}
|
128
177
|
except Exception as e:
|
129
|
-
tolog(f'
|
130
|
-
|
131
|
-
|
132
|
-
|
133
|
-
|
134
|
-
|
178
|
+
tolog(f'Failed to retrieve({filename}) due to {e}')
|
179
|
+
else:
|
180
|
+
for filename in os.listdir(src_path):
|
181
|
+
try:
|
182
|
+
file_path = os.path.join(src_path, filename)
|
183
|
+
if filename.startswith('.') or is_binary(file_path):
|
184
|
+
continue
|
185
|
+
if os.path.isfile(file_path):
|
186
|
+
with open(file_path, 'r', encoding='utf-8', errors='ignore') as f:
|
187
|
+
content = f.read()
|
188
|
+
result[file_path] = dict(timestamp=os.path.getmtime(file_path), list_str=split_str(content, max_len=max_len, get_len=get_len))
|
189
|
+
except Exception as e:
|
190
|
+
tolog(f'Failed to retrieve({filename}) due to {e}')
|
135
191
|
continue
|
136
|
-
if os.path.isfile(file_path):
|
137
|
-
with open(file_path, 'r', encoding='utf-8', errors='ignore') as f:
|
138
|
-
content = f.read()
|
139
|
-
result[file_path] = dict(timestamp=os.path.getmtime(file_path), list_str=split_str(content, max_len=max_len, get_len=get_len))
|
140
|
-
except Exception as e:
|
141
|
-
tolog(f'Skipped {filename} due to {e}', 'retrieve')
|
142
|
-
continue
|
143
192
|
return result
|
144
193
|
|
145
|
-
def
|
146
|
-
|
194
|
+
def get_path(s):
|
195
|
+
if not s:
|
196
|
+
s = '.'
|
197
|
+
s = s.strip()
|
198
|
+
s = os.path.expanduser(s)
|
199
|
+
s = os.path.abspath(s)
|
200
|
+
return s
|
147
201
|
|
148
|
-
def ingest(src):
|
202
|
+
def ingest(src, max_len=NUM_TOKEN):
|
149
203
|
def load_cache(cache_path=LTM_PATH):
|
150
204
|
if os.path.exists(cache_path):
|
151
205
|
with open(cache_path, 'r', encoding='utf-8') as f:
|
@@ -158,53 +212,135 @@ def ingest(src):
|
|
158
212
|
current_data[k] = v
|
159
213
|
with open(cache_path, 'w', encoding='utf-8') as f:
|
160
214
|
json.dump(current_data, f, indent=2)
|
161
|
-
|
162
|
-
|
163
|
-
|
164
|
-
|
215
|
+
src = get_path(src)
|
216
|
+
tolog(f'ingest {src=}')
|
217
|
+
result = ''
|
218
|
+
src_base = os.path.basename(src)
|
219
|
+
if os.path.isdir(src):
|
220
|
+
listdir = [i for i in os.listdir(src) if not i.startswith('.') and '.' in i]
|
221
|
+
result = '\n- '.join([f'--- {src_base} ---', *listdir]) + '\n\n'
|
222
|
+
elif os.path.isfile(src):
|
223
|
+
result = ''
|
224
|
+
else:
|
225
|
+
tolog(f'Failed to ingest({src})')
|
226
|
+
return ''
|
227
|
+
dict_doc = retrieve(src, max_len=max_len, get_len=chat.get_ntok)
|
228
|
+
toout(f'Ingesting {src}...')
|
229
|
+
format_ingest = '{volat}{incoming}\n\n---\n\nPlease provide a succint bullet point summary for above:'
|
230
|
+
format_volat = 'Here is a summary of part 1 of **{k}**:\n\n---\n\n{newsum}\n\n---\n\nHere is the next part:\n\n---\n\n'
|
165
231
|
dict_sum = {}
|
166
232
|
cache = load_cache()
|
233
|
+
max_new_accum = int(max_len/len(dict_doc)) if len(dict_doc) > 0 else max_len
|
167
234
|
for k, v in dict_doc.items():
|
168
|
-
v_stamp = v['timestamp']
|
169
|
-
if v_stamp == cache.get(k, {}).get('timestamp'):
|
170
|
-
dict_sum[k] = cache[k]
|
171
|
-
continue
|
172
235
|
list_str = v['list_str']
|
236
|
+
v_stamp = v['timestamp']
|
173
237
|
if len(list_str) == 0:
|
174
238
|
continue
|
175
|
-
if len(list_str)
|
176
|
-
|
239
|
+
if len(list_str) == 1 and chat.get_ntok(list_str[0]) <= max_new_accum:
|
240
|
+
chat_summary = list_str[0]
|
241
|
+
else:
|
242
|
+
k_base = os.path.basename(k)
|
243
|
+
if v_stamp == cache.get(k, {}).get('timestamp'):
|
244
|
+
dict_sum[k] = cache[k]
|
245
|
+
continue
|
246
|
+
max_new_sum = int(max_len/len(list_str))
|
177
247
|
volat = f'**{k}**:\n'
|
178
248
|
accum = ''
|
179
|
-
for s in list_str:
|
249
|
+
for i, s in enumerate(list_str):
|
180
250
|
chat.reset()
|
181
|
-
|
251
|
+
toout(f'\n\nIngesting {k_base} {i+1}/{len(list_str)}...\n\n', mode='a')
|
252
|
+
tolog(f'{s=}') # DEBUG
|
253
|
+
newsum = chat(format_ingest.format(volat=volat, incoming=s.rstrip()), max_new=max_new_sum, verbose=False, stream=OUT_PATH)['text'].rstrip()
|
254
|
+
tolog(f'{k} {i+1}/{len(list_str)}: {newsum=}') # DEBUG
|
182
255
|
accum += newsum + ' ...\n'
|
183
256
|
volat = format_volat.format(k=k, newsum=newsum)
|
184
|
-
|
185
|
-
accum
|
186
|
-
|
187
|
-
|
188
|
-
|
189
|
-
|
257
|
+
toout(f'\n\nIngesting {k_base}...\n\n', mode='a')
|
258
|
+
tolog(f'{accum=}') # DEBUG
|
259
|
+
if chat.get_ntok(accum) <= max_new_accum:
|
260
|
+
chat_summary = accum.strip()
|
261
|
+
else:
|
262
|
+
chat.reset()
|
263
|
+
chat_summary = chat(format_ingest.format(volat=f'**{k}**:\n', incoming=accum), max_new=max_new_accum, verbose=False, stream=OUT_PATH)['text'].strip()
|
264
|
+
dict_sum[k] = dict(timestamp=v_stamp, summary=chat_summary, ntok=chat.get_ntok(chat_summary))
|
190
265
|
dump_cache(dict_sum)
|
191
|
-
|
192
|
-
|
193
|
-
result += f'--- Summary of **{k}** ---\n{v["summary"]}\n\n'
|
266
|
+
for k, v in dict_sum.items():
|
267
|
+
result += f'--- **{os.path.basename(k)}** ---\n{v["summary"].strip()}\n\n'
|
194
268
|
result += '---\n\n'
|
269
|
+
toout(result, 'ingest')
|
195
270
|
return result
|
196
271
|
|
197
|
-
def process_command(data
|
198
|
-
|
199
|
-
|
200
|
-
|
201
|
-
|
202
|
-
|
203
|
-
return
|
204
|
-
|
205
|
-
|
206
|
-
|
207
|
-
|
272
|
+
def process_command(data):
|
273
|
+
if len(data['user']) == 0:
|
274
|
+
response = chat.resume(max_new=NUM_TOKEN, verbose=False, stream=OUT_PATH)
|
275
|
+
toout(response['text'], mode='a')
|
276
|
+
tolog(response)
|
277
|
+
data['user_prompt'] = ''
|
278
|
+
return data
|
279
|
+
if SEP_CMD in data['user']:
|
280
|
+
data['user_prompt'], *cmds = (x.strip() for x in data['user'].split(SEP_CMD))
|
281
|
+
else:
|
282
|
+
data['user_prompt'] = data['user'].strip()
|
283
|
+
cmds = []
|
284
|
+
tolog(f'process_command i {cmds=} {data=}')
|
285
|
+
|
286
|
+
do_reset = False if 'followup' in data else DO_RESET
|
287
|
+
for cmd in cmds:
|
288
|
+
if cmd.startswith('continue'):
|
289
|
+
arg = cmd.removeprefix('continue').strip('(').strip(')').strip().strip('"').strip("'").strip()
|
290
|
+
data['max_new'] = NUM_TOKEN if len(arg) == 0 else int(arg)
|
291
|
+
response = chat.resume(max_new=data['max_new'], verbose=False, stream=OUT_PATH)
|
292
|
+
toout(response['text'], mode='a')
|
293
|
+
tolog(response)
|
294
|
+
do_reset = False
|
295
|
+
break
|
296
|
+
|
297
|
+
if cmd.startswith('reset'):
|
298
|
+
do_reset = True
|
299
|
+
break
|
300
|
+
if cmd.startswith('followup'):
|
301
|
+
do_reset = False
|
302
|
+
break
|
303
|
+
if do_reset:
|
304
|
+
chat.reset()
|
305
|
+
|
306
|
+
full_path = data['tree']
|
307
|
+
data['dir'] = os.path.dirname(full_path)
|
308
|
+
data['file'] = os.path.basename(full_path)
|
309
|
+
data['ext'] = os.path.splitext(full_path)[1][1:]
|
310
|
+
if chat.stop:
|
311
|
+
data['file'] = ''
|
312
|
+
data['context'] = ''
|
313
|
+
if data['tree'] == OUT_PATH:
|
314
|
+
data['dir'] = os.getcwd()
|
315
|
+
data['file'] = ''
|
316
|
+
data['context'] = ''
|
317
|
+
data['ext'] = ''
|
318
|
+
if data['file'] == '.tmp':
|
319
|
+
data['file'] = ''
|
320
|
+
data['ext'] = ''
|
321
|
+
|
322
|
+
if len(cmds) == 1 and len(cmds[0]) == 0:
|
323
|
+
data['include'] = ingest(data['dir'])
|
324
|
+
return data
|
325
|
+
|
326
|
+
data['include'] = ''
|
327
|
+
for cmd in cmds:
|
328
|
+
if cmd.startswith('include'):
|
329
|
+
arg = cmd.removeprefix('include').strip('(').strip(')').strip().strip('"').strip("'").strip()
|
330
|
+
src = data['dir'] if len(arg) == 0 else arg
|
331
|
+
data['include'] += ingest(src)
|
332
|
+
|
333
|
+
for cmd in cmds:
|
334
|
+
if cmd.startswith('deploy'):
|
335
|
+
arg = cmd.removeprefix('deploy').strip('(').strip(')').strip().strip('"').strip("'").strip()
|
336
|
+
if len(data['user_prompt']) == 0:
|
337
|
+
deploy(dest=arg)
|
338
|
+
data['user_prompt'] = ''
|
339
|
+
return data
|
340
|
+
data['user_prompt'] += "\n\nEnsure that each code block is preceded by a filename in **filename.ext** format. The filename should only contain alphanumeric characters, dots, underscores, or hyphens. Ensure that any extraneous characters are removed from the filenames."
|
341
|
+
data['deploy_dest'] = arg
|
342
|
+
return data
|
343
|
+
|
208
344
|
async def monitor_directory():
|
209
345
|
async for changes in awatch(WATCH_DIR):
|
210
346
|
found_files = {os.path.basename(f) for _, f in changes}
|
@@ -220,48 +356,61 @@ async def monitor_directory():
|
|
220
356
|
if 'followup' in os.listdir(WATCH_DIR):
|
221
357
|
os.remove(os.path.join(WATCH_DIR, 'followup'))
|
222
358
|
data['followup'] = True
|
223
|
-
|
359
|
+
if 'quit' in os.listdir(WATCH_DIR):
|
360
|
+
os.remove(os.path.join(WATCH_DIR, 'quit'))
|
361
|
+
data['quit'] = True
|
224
362
|
await process_files(data)
|
225
363
|
|
226
364
|
async def process_files(data):
|
227
|
-
tolog(f'{data=}')
|
228
|
-
|
229
|
-
|
230
|
-
|
231
|
-
|
232
|
-
|
233
|
-
|
234
|
-
|
235
|
-
str_template
|
236
|
-
|
237
|
-
|
238
|
-
|
239
|
-
|
240
|
-
|
241
|
-
|
242
|
-
str_template += "```{ext}\n{yank}\n```\n\n"
|
365
|
+
tolog(f'process_files i {data=}')
|
366
|
+
str_template = '{include}'
|
367
|
+
data = process_command(data)
|
368
|
+
if len(data['user_prompt']) == 0:
|
369
|
+
return
|
370
|
+
if len(data['file']) > 0:
|
371
|
+
str_template += '**{file}**\n'
|
372
|
+
if len(data['context']) > 0 and data['yank'] != data['context']:
|
373
|
+
str_template += '```{ext}\n{context}\n```\n\n'
|
374
|
+
if len(data['yank']) > 0:
|
375
|
+
if '\n' in data['yank']:
|
376
|
+
str_template += "```{ext}\n{yank}\n```\n\n"
|
377
|
+
else:
|
378
|
+
if data['user'] == 0:
|
379
|
+
str_template += "{yank}"
|
243
380
|
else:
|
244
381
|
str_template += "`{yank}` "
|
245
|
-
|
246
|
-
|
247
|
-
|
248
|
-
str_template = process_command(data, str_template)
|
249
|
-
else:
|
250
|
-
data['user_prompt'] = data['user']
|
251
|
-
chat.reset()
|
252
|
-
tolog(f'{str_template=}') # DEBUG
|
382
|
+
str_template += '{user_prompt}'
|
383
|
+
tolog(f'process_files o {data=}') # DEBUG
|
384
|
+
tolog(f'process_files {str_template=}') # DEBUG
|
253
385
|
prompt = str_template.format(**data)
|
254
386
|
tolog(prompt, 'tollm')
|
255
387
|
toout('')
|
256
|
-
|
257
|
-
|
258
|
-
|
388
|
+
max_new = data['max_new'] if 'max_new' in data else max(10, NUM_TOKEN - chat.get_ntok(prompt))
|
389
|
+
response = chat(prompt, max_new=max_new, verbose=False, stream=OUT_PATH)
|
390
|
+
if SHOW_USER:
|
391
|
+
toout(response['text'])
|
392
|
+
else:
|
393
|
+
toout(response['text'])
|
394
|
+
tolog(response['benchmark'])
|
395
|
+
if 'deploy_dest' in data:
|
396
|
+
deploy(dest=data['deploy_dest'], reformat=False)
|
397
|
+
|
398
|
+
mapl, mapj, mapp = ('<Leader>l', '<Leader>j', '<Leader>p') if USE_LEADER else ('<C-l>', '<C-j>', '<C-p>')
|
259
399
|
|
260
400
|
VIMLMSCRIPT = Template(r"""
|
401
|
+
let s:register_names = ['a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j', 'k', 'l', 'm', 'n', 'o', 'p', 'q', 'r', 's', 't', 'u']
|
261
402
|
let s:watched_dir = expand('$WATCH_DIR')
|
262
403
|
|
263
404
|
function! Monitor()
|
264
|
-
|
405
|
+
if exists('s:monitor_timer')
|
406
|
+
call timer_stop(s:monitor_timer)
|
407
|
+
unlet s:monitor_timer
|
408
|
+
endif
|
409
|
+
let response_path = s:watched_dir . '/response.md'
|
410
|
+
let bufnum = bufnr(response_path)
|
411
|
+
if bufnum != -1
|
412
|
+
execute 'bwipeout ' . bufnum
|
413
|
+
endif
|
265
414
|
let response_path = s:watched_dir . '/response.md'
|
266
415
|
rightbelow vsplit | execute 'view ' . response_path
|
267
416
|
setlocal autoread
|
@@ -283,41 +432,17 @@ function! CheckForUpdates(timer)
|
|
283
432
|
endfunction
|
284
433
|
|
285
434
|
function! s:CustomInput(prompt) abort
|
286
|
-
|
287
|
-
let
|
288
|
-
|
289
|
-
|
290
|
-
|
291
|
-
|
292
|
-
|
293
|
-
let c = getchar()
|
294
|
-
if type(c) == v:t_number
|
295
|
-
if c == 13 " Enter
|
296
|
-
break
|
297
|
-
elseif c == 27 " Esc
|
298
|
-
let aborted = v:true
|
299
|
-
break
|
300
|
-
elseif c == 8 || c == 127 " Backspace
|
301
|
-
if len(input) > 0
|
302
|
-
let input = input[:-2]
|
303
|
-
echon "\r" . clean_prompt . input . ' '
|
304
|
-
execute "normal! \<BS>"
|
305
|
-
endif
|
306
|
-
else
|
307
|
-
let char = nr2char(c)
|
308
|
-
let input .= char
|
309
|
-
echon char
|
310
|
-
endif
|
311
|
-
endif
|
312
|
-
endwhile
|
313
|
-
let input_length = aborted ? 0 : len(input)
|
314
|
-
let clear_length = len(clean_prompt) + input_length
|
315
|
-
echon "\r" . repeat(' ', clear_length) . "\r"
|
316
|
-
return aborted ? v:null : input
|
435
|
+
call inputsave()
|
436
|
+
let input = input(a:prompt)
|
437
|
+
call inputrestore()
|
438
|
+
if empty(input)
|
439
|
+
return v:null
|
440
|
+
endif
|
441
|
+
return input
|
317
442
|
endfunction
|
318
443
|
|
319
|
-
function! SaveUserInput()
|
320
|
-
let user_input = s:CustomInput(
|
444
|
+
function! SaveUserInput(prompt)
|
445
|
+
let user_input = s:CustomInput(a:prompt)
|
321
446
|
if user_input is v:null
|
322
447
|
echo "Input aborted"
|
323
448
|
return
|
@@ -329,37 +454,105 @@ function! SaveUserInput()
|
|
329
454
|
call writefile([current_file], tree_file, 'w')
|
330
455
|
endfunction
|
331
456
|
|
332
|
-
function!
|
333
|
-
|
334
|
-
|
335
|
-
|
336
|
-
|
337
|
-
|
338
|
-
|
339
|
-
|
340
|
-
|
341
|
-
|
342
|
-
|
457
|
+
function! VisualPrompt()
|
458
|
+
silent! execute "normal! \<ESC>"
|
459
|
+
silent execute "'<,'>w! " . s:watched_dir . "/yank"
|
460
|
+
silent execute "w! " . s:watched_dir . "/context"
|
461
|
+
" silent! execute "normal! `<V`>"
|
462
|
+
" silent! execute "normal! \<ESC>"
|
463
|
+
call SaveUserInput('VimLM: ')
|
464
|
+
endfunction
|
465
|
+
|
466
|
+
function! NormalPrompt()
|
467
|
+
silent! execute "normal! V\<ESC>"
|
468
|
+
silent execute "'<,'>w! " . s:watched_dir . "/yank"
|
469
|
+
silent execute "w! " . s:watched_dir . "/context"
|
470
|
+
" silent! execute "normal! \<ESC>"
|
471
|
+
call SaveUserInput('VimLM: ')
|
343
472
|
endfunction
|
344
473
|
|
345
|
-
function!
|
474
|
+
function! FollowUpPrompt()
|
346
475
|
call writefile([], s:watched_dir . '/yank', 'w')
|
347
476
|
call writefile([], s:watched_dir . '/context', 'w')
|
348
477
|
call writefile([], s:watched_dir . '/followup', 'w')
|
349
|
-
call SaveUserInput()
|
478
|
+
call SaveUserInput('... ')
|
479
|
+
endfunction
|
480
|
+
|
481
|
+
function! ExtractAllCodeBlocks()
|
482
|
+
let filepath = s:watched_dir . '/response.md'
|
483
|
+
if !filereadable(filepath)
|
484
|
+
echoerr "File not found: " . filepath
|
485
|
+
return
|
486
|
+
endif
|
487
|
+
let lines = readfile(filepath)
|
488
|
+
let in_code_block = 0
|
489
|
+
let code_blocks = []
|
490
|
+
let current_block = []
|
491
|
+
for line in lines
|
492
|
+
if line =~ '^```'
|
493
|
+
if in_code_block
|
494
|
+
call add(code_blocks, current_block)
|
495
|
+
let current_block = []
|
496
|
+
let in_code_block = 0
|
497
|
+
else
|
498
|
+
let in_code_block = 1
|
499
|
+
endif
|
500
|
+
elseif in_code_block
|
501
|
+
call add(current_block, line)
|
502
|
+
endif
|
503
|
+
endfor
|
504
|
+
if in_code_block
|
505
|
+
call add(code_blocks, current_block)
|
506
|
+
endif
|
507
|
+
for idx in range(len(code_blocks))
|
508
|
+
if idx >= len(s:register_names)
|
509
|
+
break
|
510
|
+
endif
|
511
|
+
let code_block_text = join(code_blocks[idx], "\n")
|
512
|
+
let register_name = s:register_names[idx]
|
513
|
+
call setreg(register_name, code_block_text, 'v')
|
514
|
+
endfor
|
515
|
+
return len(code_blocks)
|
350
516
|
endfunction
|
351
517
|
|
352
|
-
|
353
|
-
|
354
|
-
|
518
|
+
function! PasteIntoLastVisualSelection()
|
519
|
+
let num_blocks = ExtractAllCodeBlocks()
|
520
|
+
echo "Extracted " . num_blocks . " blocks into registers @a-@" . s:register_names[num_blocks - 1] . ". Enter register name: "
|
521
|
+
let register_name = nr2char(getchar())
|
522
|
+
if register_name !~ '^[a-z]$'
|
523
|
+
echoerr "Invalid register name. Please enter a single lowercase letter (e.g., a, b, c)."
|
524
|
+
return
|
525
|
+
endif
|
526
|
+
let register_content = getreg(register_name)
|
527
|
+
if register_content == ''
|
528
|
+
echoerr "Register @" . register_name . " is empty."
|
529
|
+
return
|
530
|
+
endif
|
531
|
+
let current_mode = mode()
|
532
|
+
if current_mode == 'v' || current_mode == 'V' || current_mode == ''
|
533
|
+
execute 'normal! "' . register_name . 'p'
|
534
|
+
else
|
535
|
+
normal! gv
|
536
|
+
execute 'normal! "' . register_name . 'p'
|
537
|
+
endif
|
538
|
+
endfunction
|
355
539
|
|
540
|
+
nnoremap $mapp :call PasteIntoLastVisualSelection()<CR>
|
541
|
+
vnoremap $mapp <Cmd>:call PasteIntoLastVisualSelection()<CR>
|
542
|
+
vnoremap $mapl <Cmd>:call VisualPrompt()<CR>
|
543
|
+
nnoremap $mapl :call NormalPrompt()<CR>
|
544
|
+
nnoremap $mapj :call FollowUpPrompt()<CR>
|
356
545
|
call Monitor()
|
357
|
-
""").safe_substitute(dict(WATCH_DIR=WATCH_DIR))
|
546
|
+
""").safe_substitute(dict(WATCH_DIR=WATCH_DIR, mapl=mapl, mapj=mapj, mapp=mapp))
|
358
547
|
|
359
548
|
async def main():
|
360
549
|
parser = argparse.ArgumentParser(description="VimLM - LLM-powered Vim assistant")
|
550
|
+
parser.add_argument('--test', action='store_true')
|
361
551
|
parser.add_argument("vim_args", nargs=argparse.REMAINDER, help="Vim arguments")
|
362
552
|
args = parser.parse_args()
|
553
|
+
if args.test:
|
554
|
+
test()
|
555
|
+
return
|
363
556
|
with tempfile.NamedTemporaryFile(mode='w', suffix='.vim', delete=False) as f:
|
364
557
|
f.write(VIMLMSCRIPT)
|
365
558
|
vim_script = f.name
|
@@ -368,14 +561,17 @@ async def main():
|
|
368
561
|
vim_command.extend(args.vim_args)
|
369
562
|
else:
|
370
563
|
vim_command.append('.tmp')
|
371
|
-
monitor_task = asyncio.create_task(monitor_directory())
|
372
|
-
vim_process = await asyncio.create_subprocess_exec(*vim_command)
|
373
|
-
await vim_process.wait()
|
374
|
-
monitor_task.cancel()
|
375
564
|
try:
|
376
|
-
|
377
|
-
|
378
|
-
|
565
|
+
monitor_task = asyncio.create_task(monitor_directory())
|
566
|
+
vim_process = await asyncio.create_subprocess_exec(*vim_command)
|
567
|
+
await vim_process.wait()
|
568
|
+
finally:
|
569
|
+
monitor_task.cancel()
|
570
|
+
try:
|
571
|
+
await monitor_task
|
572
|
+
except asyncio.CancelledError:
|
573
|
+
pass
|
574
|
+
os.remove(vim_script)
|
379
575
|
|
380
576
|
def run():
|
381
577
|
asyncio.run(main())
|
vimlm-0.0.4.dist-info/METADATA
DELETED
@@ -1,82 +0,0 @@
|
|
1
|
-
Metadata-Version: 2.2
|
2
|
-
Name: vimlm
|
3
|
-
Version: 0.0.4
|
4
|
-
Summary: VimLM - LLM-powered Vim assistant
|
5
|
-
Home-page: https://github.com/JosefAlbers/vimlm
|
6
|
-
Author: Josef Albers
|
7
|
-
Author-email: albersj66@gmail.com
|
8
|
-
Requires-Python: >=3.12.8
|
9
|
-
Description-Content-Type: text/markdown
|
10
|
-
License-File: LICENSE
|
11
|
-
Requires-Dist: nanollama==0.0.3b0
|
12
|
-
Requires-Dist: watchfiles==1.0.4
|
13
|
-
Dynamic: author
|
14
|
-
Dynamic: author-email
|
15
|
-
Dynamic: description
|
16
|
-
Dynamic: description-content-type
|
17
|
-
Dynamic: home-page
|
18
|
-
Dynamic: requires-dist
|
19
|
-
Dynamic: requires-python
|
20
|
-
Dynamic: summary
|
21
|
-
|
22
|
-
# VimLM - Vim Language Model Assistant for privacy-conscious developers
|
23
|
-
|
24
|
-

|
25
|
-
|
26
|
-
An LLM-powered coding companion for Vim, inspired by GitHub Copilot/Cursor. Integrates contextual code understanding, summarization, and AI assistance directly into your Vim workflow.
|
27
|
-
|
28
|
-
## Features
|
29
|
-
|
30
|
-
- **Real-Time Interaction with local LLMs**: Runs **fully offline** with local models (default: uncensored Llama-3.2-3B).
|
31
|
-
- **Integration with Vim's Native Workflow**: Simple Vim keybindings for quick access and split-window interface for non-intrusive responses.
|
32
|
-
- **Context-Awareness Beyond Single Files**: Inline support for external documents and project files for richer, more informed responses.
|
33
|
-
- **Conversational AI Assistance**: Goes beyond simple code suggestions-explains reasoning, provides alternative solutions, and allows interactive follow-ups.
|
34
|
-
- **Versatile Use Cases**: Not just for coding-use it for documentation lookup, general Q&A, or even casual (uncensored) conversations.
|
35
|
-
|
36
|
-
## Installation
|
37
|
-
|
38
|
-
```zsh
|
39
|
-
pip install vimlm
|
40
|
-
```
|
41
|
-
|
42
|
-
## Usage
|
43
|
-
|
44
|
-
1. Start Vim with VimLM:
|
45
|
-
|
46
|
-
```zsh
|
47
|
-
vimlm
|
48
|
-
```
|
49
|
-
|
50
|
-
or
|
51
|
-
|
52
|
-
```zsh
|
53
|
-
vimlm your_file.js
|
54
|
-
```
|
55
|
-
|
56
|
-
2. **From Normal Mode**:
|
57
|
-
- `Ctrl-l`: Send current line + file context
|
58
|
-
- Example prompt: "Regex for removing html tags in item.content"
|
59
|
-
|
60
|
-
3. **From Visual Mode**:
|
61
|
-
- Select text → `Ctrl-l`: Send selection + file context
|
62
|
-
- Example prompt: "Convert this to async/await syntax"
|
63
|
-
|
64
|
-
4. **Add Context**: Use `!@#$` to include additional files/folders:
|
65
|
-
- `!@#$` (no path): Current folder
|
66
|
-
- `!@#$ ~/scrap/jph00/hypermedia-applications.summ.md`: Specific folder
|
67
|
-
- `!@#$ ~/wtm/utils.py`: Specific file
|
68
|
-
- Example prompt: "AJAX-ify this app !@#$ ~/scrap/jph00/hypermedia-applications.summ.md"
|
69
|
-
|
70
|
-
5. **Follow-Up**: After initial response:
|
71
|
-
- `Ctrl-r`: Continue thread
|
72
|
-
- Example follow-up: "In Manifest V3"
|
73
|
-
|
74
|
-
## Key Bindings
|
75
|
-
|
76
|
-
| Binding | Mode | Action |
|
77
|
-
|------------|---------------|----------------------------------------|
|
78
|
-
| `Ctrl-l` | Normal/Visual | Send current file + selection to LLM |
|
79
|
-
| `Ctrl-r` | Normal | Continue conversation |
|
80
|
-
| `Esc` | Prompt | Cancel input |
|
81
|
-
|
82
|
-
|
vimlm-0.0.4.dist-info/RECORD
DELETED
@@ -1,7 +0,0 @@
|
|
1
|
-
vimlm.py,sha256=kCJphqs6B-ZUGzeikiW3u629Nw5Ih3DlDJV1rsKGXAY,13410
|
2
|
-
vimlm-0.0.4.dist-info/LICENSE,sha256=f1xgK8fAXg_intwnbc9nLkHf7ODPLtgpHs7DetQHOro,11343
|
3
|
-
vimlm-0.0.4.dist-info/METADATA,sha256=EdZyb9SyLpy0qyK_jILHa7nrd5G8lPxOm6Xfy7NMfiQ,2832
|
4
|
-
vimlm-0.0.4.dist-info/WHEEL,sha256=In9FTNxeP60KnTkGw7wk6mJPYd_dQSjEZmXdBdMCI-8,91
|
5
|
-
vimlm-0.0.4.dist-info/entry_points.txt,sha256=mU5V4MYsuIzCc6YB-Ro-6USSHWN5vHw8UDnTEoq0isw,36
|
6
|
-
vimlm-0.0.4.dist-info/top_level.txt,sha256=I8GjqoiP--scYsO3AfLhha-6Ax9ci3IvbWvVbPv8g94,6
|
7
|
-
vimlm-0.0.4.dist-info/RECORD,,
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|