vimlm 0.0.2__py3-none-any.whl → 0.0.4__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.
@@ -1,14 +1,14 @@
1
1
  Metadata-Version: 2.2
2
2
  Name: vimlm
3
- Version: 0.0.2
3
+ Version: 0.0.4
4
4
  Summary: VimLM - LLM-powered Vim assistant
5
5
  Home-page: https://github.com/JosefAlbers/vimlm
6
6
  Author: Josef Albers
7
7
  Author-email: albersj66@gmail.com
8
- Requires-Python: >=3.13.1
8
+ Requires-Python: >=3.12.8
9
9
  Description-Content-Type: text/markdown
10
10
  License-File: LICENSE
11
- Requires-Dist: nanollama==0.0.3
11
+ Requires-Dist: nanollama==0.0.3b0
12
12
  Requires-Dist: watchfiles==1.0.4
13
13
  Dynamic: author
14
14
  Dynamic: author-email
@@ -21,11 +21,9 @@ Dynamic: summary
21
21
 
22
22
  # VimLM - Vim Language Model Assistant for privacy-conscious developers
23
23
 
24
- ![vimlm](https://github.com/user-attachments/assets/4aa39efe-aa6d-4363-8fe1-cf964d7f849c)
24
+ ![vimlm](https://github.com/user-attachments/assets/67a97048-48df-40c1-9109-53c759e85d96)
25
25
 
26
- VimLM brings AI-powered assistance directly into Vim, keeping your workflow smooth and uninterrupted. Instead of constantly switching between your editor and external tools, VimLM provides real-time, context-aware suggestions, explanations, and code insights—all within Vim.
27
-
28
- Unlike proprietary cloud-based AI models, VimLM runs entirely offline, ensuring complete privacy, data security, and control. You’re not just using a tool—you own it. Fine-tune, modify, or extend it as needed, without reliance on big tech platforms.
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.
29
27
 
30
28
  ## Features
31
29
 
@@ -70,15 +68,15 @@ vimlm your_file.js
70
68
  - Example prompt: "AJAX-ify this app !@#$ ~/scrap/jph00/hypermedia-applications.summ.md"
71
69
 
72
70
  5. **Follow-Up**: After initial response:
73
- - `Ctrl-R`: Continue thread
71
+ - `Ctrl-r`: Continue thread
74
72
  - Example follow-up: "In Manifest V3"
75
73
 
76
74
  ## Key Bindings
77
75
 
78
76
  | Binding | Mode | Action |
79
77
  |------------|---------------|----------------------------------------|
80
- | `Ctrl-L` | Normal/Visual | Send current file + selection to LLM |
81
- | `Ctrl-R` | Normal | Continue conversation |
78
+ | `Ctrl-l` | Normal/Visual | Send current file + selection to LLM |
79
+ | `Ctrl-r` | Normal | Continue conversation |
82
80
  | `Esc` | Prompt | Cancel input |
83
81
 
84
82
 
@@ -0,0 +1,7 @@
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,,
@@ -0,0 +1 @@
1
+ vimlm
vimlm.py ADDED
@@ -0,0 +1,384 @@
1
+ # Copyright 2025 Josef Albers
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # http://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+
15
+ import asyncio
16
+ import subprocess
17
+ import json
18
+ import os
19
+ from watchfiles import awatch
20
+ from nanollama32 import Chat
21
+ import shutil
22
+ import time
23
+ from itertools import accumulate
24
+ import argparse
25
+ import tempfile
26
+ from pathlib import Path
27
+ from string import Template
28
+
29
+ DEBUG = True
30
+ NUM_TOKEN = 2000
31
+ SEP_CMD = '!@#$'
32
+ VIMLM_DIR = os.path.expanduser("~/vimlm")
33
+ WATCH_DIR = os.path.expanduser("~/vimlm/watch_dir")
34
+ CFG_FILE = "cfg.json"
35
+ LOG_FILE = "log.json"
36
+ LTM_FILE = "cache.json"
37
+ OUT_FILE = "response.md"
38
+ IN_FILES = ["context", "yank", "user", "tree"]
39
+ CFG_PATH = os.path.join(VIMLM_DIR, CFG_FILE)
40
+ LOG_PATH = os.path.join(VIMLM_DIR, LOG_FILE)
41
+ LTM_PATH = os.path.join(VIMLM_DIR, LTM_FILE)
42
+ OUT_PATH = os.path.join(WATCH_DIR, OUT_FILE)
43
+
44
+ if os.path.exists(WATCH_DIR):
45
+ shutil.rmtree(WATCH_DIR)
46
+ os.makedirs(WATCH_DIR)
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:
60
+ f.write(s)
61
+ tolog(s, key)
62
+
63
+ def tolog(log, key='debug'):
64
+ if not DEBUG and key == 'debug':
65
+ return
66
+ if os.path.exists(LOG_PATH):
67
+ with open(LOG_PATH, "r", encoding="utf-8") as log_f:
68
+ logs = json.load(log_f)
69
+ else:
70
+ logs = []
71
+ logs.append(dict(key=key, log=log, timestamp=time.ctime()))
72
+ with open(LOG_PATH, "w", encoding="utf-8") as log_f:
73
+ json.dump(logs, log_f, indent=2)
74
+
75
+ toout('Loading LLM...')
76
+ chat = Chat(variant='uncn_llama_32_3b_it')
77
+ toout('LLM is ready')
78
+
79
+ def is_binary(file_path):
80
+ try:
81
+ with open(file_path, 'rb') as f:
82
+ chunk = f.read(1024)
83
+ chunk.decode('utf-8')
84
+ return False
85
+ except UnicodeDecodeError:
86
+ return True
87
+ except Exception as e:
88
+ return f"Error: {e}"
89
+
90
+ def split_str(doc, max_len=2000, get_len=len):
91
+ chunks, current_chunk, current_len = [], [], 0
92
+ lines = doc.splitlines(keepends=True)
93
+ atomic_chunks, temp = [], []
94
+ for line in lines:
95
+ if line.strip():
96
+ temp.append(line)
97
+ else:
98
+ if temp:
99
+ atomic_chunks.append("".join(temp))
100
+ temp = []
101
+ atomic_chunks.append(line)
102
+ if temp:
103
+ atomic_chunks.append("".join(temp))
104
+ for chunk in atomic_chunks:
105
+ if current_len + get_len(chunk) > max_len and current_chunk:
106
+ chunks.append("".join(current_chunk))
107
+ current_chunk, current_len = [], 0
108
+ current_chunk.append(chunk)
109
+ current_len += get_len(chunk)
110
+ if current_chunk:
111
+ if current_len < max_len / 2 and len(chunks) > 0:
112
+ chunks[-1] += "".join(current_chunk)
113
+ else:
114
+ chunks.append("".join(current_chunk))
115
+ return chunks
116
+
117
+ def retrieve(src_path, max_len=2000, get_len=len):
118
+ src_path = os.path.expanduser(src_path)
119
+ result = {}
120
+ if not os.path.exists(src_path):
121
+ tolog(f"The path {src_path} does not exist.", 'retrieve')
122
+ return result
123
+ if os.path.isfile(src_path):
124
+ try:
125
+ with open(src_path, 'r', encoding='utf-8', errors='ignore') as f:
126
+ content = f.read()
127
+ result = {src_path:dict(timestamp=os.path.getmtime(src_path), list_str=split_str(content, max_len=max_len, get_len=get_len))}
128
+ except Exception as e:
129
+ tolog(f'Skipped {filename} due to {e}', 'retrieve')
130
+ return result
131
+ for filename in os.listdir(src_path):
132
+ try:
133
+ file_path = os.path.join(src_path, filename)
134
+ if filename.startswith('.') or is_binary(file_path):
135
+ 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
+ return result
144
+
145
+ def get_ntok(s):
146
+ return len(chat.tokenizer.encode(s)[0])
147
+
148
+ def ingest(src):
149
+ def load_cache(cache_path=LTM_PATH):
150
+ if os.path.exists(cache_path):
151
+ with open(cache_path, 'r', encoding='utf-8') as f:
152
+ return json.load(f)
153
+ return {}
154
+ def dump_cache(new_data, cache_path=LTM_PATH):
155
+ current_data = load_cache(cache_path)
156
+ for k, v in new_data.items():
157
+ if k not in current_data or v['timestamp'] > current_data[k]['timestamp']:
158
+ current_data[k] = v
159
+ with open(cache_path, 'w', encoding='utf-8') as f:
160
+ json.dump(current_data, f, indent=2)
161
+ toout('Ingesting...')
162
+ format_ingest = '{volat}{incoming}\n\n---\n\nPlease provide a succint bullet point summary for above:'
163
+ 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'
164
+ dict_doc = retrieve(src, get_len=get_ntok)
165
+ dict_sum = {}
166
+ cache = load_cache()
167
+ 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
+ list_str = v['list_str']
173
+ if len(list_str) == 0:
174
+ continue
175
+ if len(list_str) > 1:
176
+ max_new_sum = int(NUM_TOKEN / len(list_str) / 2)
177
+ volat = f'**{k}**:\n'
178
+ accum = ''
179
+ for s in list_str:
180
+ chat.reset()
181
+ newsum = chat(format_ingest.format(volat=volat, incoming=s.strip()), max_new=max_new_sum, verbose=False, stream=None)[0][:-10].strip()
182
+ accum += newsum + ' ...\n'
183
+ volat = format_volat.format(k=k, newsum=newsum)
184
+ else:
185
+ accum = list_str[0]
186
+ chat.reset()
187
+ toout('')
188
+ chat_summary = chat(format_ingest.format(volat=f'**{k}**:\n', incoming=accum), max_new=int(NUM_TOKEN/4), verbose=False, stream=OUT_PATH)[0][:-10].strip()
189
+ dict_sum[k] = dict(timestamp=v_stamp, summary=chat_summary)
190
+ dump_cache(dict_sum)
191
+ result = ''
192
+ for (k,v) in dict_sum.items():
193
+ result += f'--- Summary of **{k}** ---\n{v["summary"]}\n\n'
194
+ result += '---\n\n'
195
+ return result
196
+
197
+ def process_command(data, str_template):
198
+ try:
199
+ if len(data['user_command'].strip()) > 0:
200
+ src = data['user_command']
201
+ else:
202
+ src = data['dir']
203
+ return ingest(src) + str_template
204
+ except Exception as e:
205
+ tolog(f'Failed to ingest due to {e}', 'process_command')
206
+ return str_template
207
+
208
+ async def monitor_directory():
209
+ async for changes in awatch(WATCH_DIR):
210
+ found_files = {os.path.basename(f) for _, f in changes}
211
+ tolog(f'{found_files=}') # DEBUG
212
+ if IN_FILES[-1] in found_files and set(IN_FILES).issubset(set(os.listdir(WATCH_DIR))):
213
+ tolog(f'listdir()={os.listdir(WATCH_DIR)}') # DEBUG
214
+ data = {}
215
+ for file in IN_FILES:
216
+ path = os.path.join(WATCH_DIR, file)
217
+ with open(path, 'r', encoding='utf-8') as f:
218
+ data[file] = f.read().strip()
219
+ os.remove(os.path.join(WATCH_DIR, file))
220
+ if 'followup' in os.listdir(WATCH_DIR):
221
+ os.remove(os.path.join(WATCH_DIR, 'followup'))
222
+ data['followup'] = True
223
+ str_template = '{user}'
224
+ await process_files(data)
225
+
226
+ async def process_files(data):
227
+ tolog(f'{data=}')
228
+ if 'followup' in data:
229
+ str_template = "{user}"
230
+ else:
231
+ full_path = data['tree']
232
+ data['dir'] = os.path.dirname(full_path)
233
+ data['file'] = os.path.basename(full_path)
234
+ data['ext'] = os.path.splitext(full_path)[1][1:]
235
+ str_template = ''
236
+ if len(data['file']) > 0 and data['file'] != '.tmp':
237
+ str_template += '**{file}**\n'
238
+ if len(data['context']) > 0:
239
+ str_template += '```{ext}\n{context}\n```\n\n'
240
+ if len(data['yank']) > 0:
241
+ if '\n' in data['yank']:
242
+ str_template += "```{ext}\n{yank}\n```\n\n"
243
+ else:
244
+ str_template += "`{yank}` "
245
+ str_template += '{user_prompt}'
246
+ if SEP_CMD in data['user']:
247
+ data['user_prompt'], data['user_command'] = (x.strip() for x in data['user'].split(SEP_CMD))
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
253
+ prompt = str_template.format(**data)
254
+ tolog(prompt, 'tollm')
255
+ toout('')
256
+ response = chat(prompt, max_new=NUM_TOKEN - get_ntok(prompt), verbose=False, stream=OUT_PATH)
257
+ toout(response[0][:-10].strip())
258
+ tolog(response[-1], 'tps')
259
+
260
+ VIMLMSCRIPT = Template(r"""
261
+ let s:watched_dir = expand('$WATCH_DIR')
262
+
263
+ function! Monitor()
264
+ write
265
+ let response_path = s:watched_dir . '/response.md'
266
+ rightbelow vsplit | execute 'view ' . response_path
267
+ setlocal autoread
268
+ setlocal readonly
269
+ setlocal nobuflisted
270
+ filetype detect
271
+ syntax on
272
+ wincmd h
273
+ let s:monitor_timer = timer_start(100, 'CheckForUpdates', {'repeat': -1})
274
+ endfunction
275
+
276
+ function! CheckForUpdates(timer)
277
+ let bufnum = bufnr(s:watched_dir . '/response.md')
278
+ if bufnum == -1
279
+ call timer_stop(s:monitor_timer)
280
+ return
281
+ endif
282
+ silent! checktime
283
+ endfunction
284
+
285
+ function! s:CustomInput(prompt) abort
286
+ let input = ''
287
+ let clean_prompt = a:prompt
288
+ let aborted = v:false
289
+ echohl Question
290
+ echon clean_prompt
291
+ echohl None
292
+ while 1
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
317
+ endfunction
318
+
319
+ function! SaveUserInput()
320
+ let user_input = s:CustomInput('Ask LLM: ')
321
+ if user_input is v:null
322
+ echo "Input aborted"
323
+ return
324
+ endif
325
+ let user_file = s:watched_dir . '/user'
326
+ call writefile([user_input], user_file, 'w')
327
+ let current_file = expand('%:p')
328
+ let tree_file = s:watched_dir . '/tree'
329
+ call writefile([current_file], tree_file, 'w')
330
+ endfunction
331
+
332
+ function! SaveUserInput()
333
+ let user_input = s:CustomInput('Ask LLM: ')
334
+ if user_input is v:null
335
+ echo "Input aborted"
336
+ return
337
+ endif
338
+ let user_file = s:watched_dir . '/user'
339
+ call writefile([user_input], user_file, 'w')
340
+ let current_file = expand('%:p')
341
+ let tree_file = s:watched_dir . '/tree'
342
+ call writefile([current_file], tree_file, 'w')
343
+ endfunction
344
+
345
+ function! FollowUpInput()
346
+ call writefile([], s:watched_dir . '/yank', 'w')
347
+ call writefile([], s:watched_dir . '/context', 'w')
348
+ call writefile([], s:watched_dir . '/followup', 'w')
349
+ call SaveUserInput()
350
+ endfunction
351
+
352
+ vnoremap <C-l> :w! $WATCH_DIR/yank<CR>:w! $WATCH_DIR/context<CR>:call SaveUserInput()<CR>
353
+ nnoremap <C-l> V:w! $WATCH_DIR/yank<CR>:w! $WATCH_DIR/context<CR>:call SaveUserInput()<CR>
354
+ nnoremap <C-r> :call FollowUpInput()<CR>
355
+
356
+ call Monitor()
357
+ """).safe_substitute(dict(WATCH_DIR=WATCH_DIR))
358
+
359
+ async def main():
360
+ parser = argparse.ArgumentParser(description="VimLM - LLM-powered Vim assistant")
361
+ parser.add_argument("vim_args", nargs=argparse.REMAINDER, help="Vim arguments")
362
+ args = parser.parse_args()
363
+ with tempfile.NamedTemporaryFile(mode='w', suffix='.vim', delete=False) as f:
364
+ f.write(VIMLMSCRIPT)
365
+ vim_script = f.name
366
+ vim_command = ["vim", "-c", f"source {vim_script}"]
367
+ if args.vim_args:
368
+ vim_command.extend(args.vim_args)
369
+ else:
370
+ 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
+ try:
376
+ await monitor_task
377
+ except asyncio.CancelledError:
378
+ pass
379
+
380
+ def run():
381
+ asyncio.run(main())
382
+
383
+ if __name__ == '__main__':
384
+ run()
@@ -1,6 +0,0 @@
1
- vimlm-0.0.2.dist-info/LICENSE,sha256=f1xgK8fAXg_intwnbc9nLkHf7ODPLtgpHs7DetQHOro,11343
2
- vimlm-0.0.2.dist-info/METADATA,sha256=ntusBBeCTMoPlEFM7HBrHzCU1scKWQ09Dx6QA_Rod0s,3178
3
- vimlm-0.0.2.dist-info/WHEEL,sha256=In9FTNxeP60KnTkGw7wk6mJPYd_dQSjEZmXdBdMCI-8,91
4
- vimlm-0.0.2.dist-info/entry_points.txt,sha256=mU5V4MYsuIzCc6YB-Ro-6USSHWN5vHw8UDnTEoq0isw,36
5
- vimlm-0.0.2.dist-info/top_level.txt,sha256=AbpHGcgLb-kRsJGnwFEktk7uzpZOCcBY74-YBdrKVGs,1
6
- vimlm-0.0.2.dist-info/RECORD,,
@@ -1 +0,0 @@
1
-
File without changes
File without changes