vimlm 0.0.2__py3-none-any.whl → 0.0.3__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,6 +1,6 @@
1
1
  Metadata-Version: 2.2
2
2
  Name: vimlm
3
- Version: 0.0.2
3
+ Version: 0.0.3
4
4
  Summary: VimLM - LLM-powered Vim assistant
5
5
  Home-page: https://github.com/JosefAlbers/vimlm
6
6
  Author: Josef Albers
@@ -0,0 +1,7 @@
1
+ vimlm.py,sha256=ku7NOIrcCNHzRYP9_8qp1NX8w5uCiZAlf0ijMKJXTlw,12996
2
+ vimlm-0.0.3.dist-info/LICENSE,sha256=f1xgK8fAXg_intwnbc9nLkHf7ODPLtgpHs7DetQHOro,11343
3
+ vimlm-0.0.3.dist-info/METADATA,sha256=i4W8tmpaMH1m86vzEwNpwo9BUfYcLzr3nksEU_iGW6Y,3178
4
+ vimlm-0.0.3.dist-info/WHEEL,sha256=In9FTNxeP60KnTkGw7wk6mJPYd_dQSjEZmXdBdMCI-8,91
5
+ vimlm-0.0.3.dist-info/entry_points.txt,sha256=mU5V4MYsuIzCc6YB-Ro-6USSHWN5vHw8UDnTEoq0isw,36
6
+ vimlm-0.0.3.dist-info/top_level.txt,sha256=I8GjqoiP--scYsO3AfLhha-6Ax9ci3IvbWvVbPv8g94,6
7
+ vimlm-0.0.3.dist-info/RECORD,,
@@ -0,0 +1 @@
1
+ vimlm
vimlm.py ADDED
@@ -0,0 +1,371 @@
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 = False
30
+ NUM_TOKEN = 2000
31
+ SEP_CMD = '!@#$'
32
+ CACHE_DIR = os.path.expanduser("~/vimlm")
33
+ WATCH_DIR = os.path.expanduser("~/vimlm/watch_dir")
34
+ LOG_FILE = "log.json"
35
+ CACHE_FILE = "cache.json"
36
+ OUT_FILE = "response.md"
37
+ FILES = ["context", "yank", "user", "tree"]
38
+ LOG_PATH = os.path.join(CACHE_DIR, LOG_FILE)
39
+ CACHE_PATH = os.path.join(CACHE_DIR, CACHE_FILE)
40
+ OUT_PATH = os.path.join(WATCH_DIR, OUT_FILE)
41
+
42
+ if os.path.exists(WATCH_DIR):
43
+ shutil.rmtree(WATCH_DIR)
44
+ os.makedirs(WATCH_DIR)
45
+
46
+ def toout(s, key='tovim'):
47
+ with open(OUT_PATH, 'w', encoding='utf-8') as f:
48
+ f.write(s)
49
+ tolog(s, key)
50
+
51
+ def tolog(log, key='debug'):
52
+ if not DEBUG and key == 'debug':
53
+ return
54
+ if os.path.exists(LOG_PATH):
55
+ with open(LOG_PATH, "r", encoding="utf-8") as log_f:
56
+ logs = json.load(log_f)
57
+ else:
58
+ logs = []
59
+ logs.append(dict(key=key, log=log, timestamp=time.ctime()))
60
+ with open(LOG_PATH, "w", encoding="utf-8") as log_f:
61
+ json.dump(logs, log_f, indent=2)
62
+
63
+ toout('Loading LLM...')
64
+ chat = Chat(variant='uncn_llama_32_3b_it')
65
+ toout('LLM is ready')
66
+
67
+ def is_binary(file_path):
68
+ try:
69
+ with open(file_path, 'rb') as f:
70
+ chunk = f.read(1024)
71
+ chunk.decode('utf-8')
72
+ return False
73
+ except UnicodeDecodeError:
74
+ return True
75
+ except Exception as e:
76
+ return f"Error: {e}"
77
+
78
+ def split_str(doc, max_len=2000, get_len=len):
79
+ chunks, current_chunk, current_len = [], [], 0
80
+ lines = doc.splitlines(keepends=True)
81
+ atomic_chunks, temp = [], []
82
+ for line in lines:
83
+ if line.strip():
84
+ temp.append(line)
85
+ else:
86
+ if temp:
87
+ atomic_chunks.append("".join(temp))
88
+ temp = []
89
+ atomic_chunks.append(line)
90
+ if temp:
91
+ atomic_chunks.append("".join(temp))
92
+ for chunk in atomic_chunks:
93
+ if current_len + get_len(chunk) > max_len and current_chunk:
94
+ chunks.append("".join(current_chunk))
95
+ current_chunk, current_len = [], 0
96
+ current_chunk.append(chunk)
97
+ current_len += get_len(chunk)
98
+ if current_chunk:
99
+ if current_len < max_len / 2 and len(chunks) > 0:
100
+ chunks[-1] += "".join(current_chunk)
101
+ else:
102
+ chunks.append("".join(current_chunk))
103
+ return chunks
104
+
105
+ def get_context(src_path, max_len=2000, get_len=len):
106
+ src_path = os.path.expanduser(src_path)
107
+ result = {}
108
+ if not os.path.exists(src_path):
109
+ tolog(f"The path {src_path} does not exist.", 'get_context')
110
+ return result
111
+ if os.path.isfile(src_path):
112
+ try:
113
+ with open(src_path, 'r', encoding='utf-8', errors='ignore') as f:
114
+ content = f.read()
115
+ result = {src_path:dict(timestamp=os.path.getmtime(src_path), list_str=split_str(content, max_len=max_len, get_len=get_len))}
116
+ except Exception as e:
117
+ tolog(f'Skipped {filename} due to {e}', 'get_context')
118
+ return result
119
+ for filename in os.listdir(src_path):
120
+ try:
121
+ file_path = os.path.join(src_path, filename)
122
+ if filename.startswith('.') or is_binary(file_path):
123
+ continue
124
+ if os.path.isfile(file_path):
125
+ with open(file_path, 'r', encoding='utf-8', errors='ignore') as f:
126
+ content = f.read()
127
+ result[file_path] = dict(timestamp=os.path.getmtime(file_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}', 'get_context')
130
+ continue
131
+ return result
132
+
133
+ def get_ntok(s):
134
+ return len(chat.tokenizer.encode(s)[0])
135
+
136
+ def ingest(src):
137
+ def load_cache(cache_path=CACHE_PATH):
138
+ if os.path.exists(cache_path):
139
+ with open(cache_path, 'r', encoding='utf-8') as f:
140
+ return json.load(f)
141
+ return {}
142
+ def dump_cache(new_data, cache_path=CACHE_PATH):
143
+ current_data = load_cache(cache_path)
144
+ for k, v in new_data.items():
145
+ if k not in current_data or v['timestamp'] > current_data[k]['timestamp']:
146
+ current_data[k] = v
147
+ with open(cache_path, 'w', encoding='utf-8') as f:
148
+ json.dump(current_data, f, indent=2)
149
+ toout('Ingesting...')
150
+ format_ingest = '{volat}{incoming}\n\n---\n\nPlease provide a succint bullet point summary for above:'
151
+ 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'
152
+ dict_doc = get_context(src, get_len=get_ntok)
153
+ dict_sum = {}
154
+ cache = load_cache()
155
+ for k, v in dict_doc.items():
156
+ v_stamp = v['timestamp']
157
+ if v_stamp == cache.get(k, {}).get('timestamp'):
158
+ dict_sum[k] = cache[k]
159
+ continue
160
+ list_str = v['list_str']
161
+ if len(list_str) == 0:
162
+ continue
163
+ if len(list_str) > 1:
164
+ max_new_sum = int(NUM_TOKEN / len(list_str) / 2)
165
+ volat = f'**{k}**:\n'
166
+ accum = ''
167
+ for s in list_str:
168
+ chat.reset()
169
+ newsum = chat(format_ingest.format(volat=volat, incoming=s.strip()), max_new=max_new_sum, verbose=False, stream=None)[0][:-10].strip()
170
+ accum += newsum + ' ...\n'
171
+ volat = format_volat.format(k=k, newsum=newsum)
172
+ else:
173
+ accum = list_str[0]
174
+ chat.reset()
175
+ toout('')
176
+ 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()
177
+ dict_sum[k] = dict(timestamp=v_stamp, summary=chat_summary)
178
+ dump_cache(dict_sum)
179
+ result = ''
180
+ for (k,v) in dict_sum.items():
181
+ result += f'--- Summary of **{k}** ---\n{v["summary"]}\n\n'
182
+ result += '---\n\n'
183
+ return result
184
+
185
+ def process_command(data, str_template):
186
+ try:
187
+ if len(data['user_command'].strip()) > 0:
188
+ src = data['user_command']
189
+ else:
190
+ src = data['dir']
191
+ return ingest(src) + str_template
192
+ except Exception as e:
193
+ tolog(f'Failed to ingest due to {e}', 'process_command')
194
+ return str_template
195
+
196
+ async def monitor_directory():
197
+ async for changes in awatch(WATCH_DIR):
198
+ found_files = {os.path.basename(f) for _, f in changes}
199
+ tolog(f'{found_files=}') # DEBUG
200
+ if FILES[-1] in found_files and set(FILES).issubset(set(os.listdir(WATCH_DIR))):
201
+ tolog(f'listdir()={os.listdir(WATCH_DIR)}') # DEBUG
202
+ data = {}
203
+ for file in FILES:
204
+ path = os.path.join(WATCH_DIR, file)
205
+ with open(path, 'r', encoding='utf-8') as f:
206
+ data[file] = f.read().strip()
207
+ os.remove(os.path.join(WATCH_DIR, file))
208
+ if 'followup' in os.listdir(WATCH_DIR):
209
+ os.remove(os.path.join(WATCH_DIR, 'followup'))
210
+ data['followup'] = True
211
+ str_template = '{user}'
212
+ await process_files(data)
213
+
214
+ async def process_files(data):
215
+ tolog(f'{data=}')
216
+ if 'followup' in data:
217
+ str_template = "{user}"
218
+ else:
219
+ full_path = data['tree']
220
+ data['dir'] = os.path.dirname(full_path)
221
+ data['file'] = os.path.basename(full_path)
222
+ data['ext'] = os.path.splitext(full_path)[1][1:]
223
+ str_template = ''
224
+ if len(data['file']) > 0 and data['file'] != '.tmp':
225
+ str_template += '**{file}**\n'
226
+ if len(data['context']) > 0:
227
+ str_template += '```{ext}\n{context}\n```\n\n'
228
+ if len(data['yank']) > 0:
229
+ if '\n' in data['yank']:
230
+ str_template += "```{ext}\n{yank}\n```\n\n"
231
+ else:
232
+ str_template += "`{yank}` "
233
+ str_template += '{user_prompt}'
234
+ if SEP_CMD in data['user']:
235
+ data['user_prompt'], data['user_command'] = (x.strip() for x in data['user'].split(SEP_CMD))
236
+ str_template = process_command(data, str_template)
237
+ else:
238
+ data['user_prompt'] = data['user']
239
+ chat.reset()
240
+ tolog(f'{str_template=}') # DEBUG
241
+ prompt = str_template.format(**data)
242
+ tolog(prompt, 'tollm')
243
+ toout('')
244
+ response = chat(prompt, max_new=NUM_TOKEN - get_ntok(prompt), verbose=False, stream=OUT_PATH)[0][:-10].strip()
245
+ toout(response)
246
+
247
+ VIMLMSCRIPT = Template(r"""
248
+ let s:watched_dir = expand('$WATCH_DIR')
249
+
250
+ function! Monitor()
251
+ write
252
+ let response_path = s:watched_dir . '/response.md'
253
+ rightbelow vsplit | execute 'view ' . response_path
254
+ setlocal autoread
255
+ setlocal readonly
256
+ setlocal nobuflisted
257
+ filetype detect
258
+ syntax on
259
+ wincmd h
260
+ let s:monitor_timer = timer_start(100, 'CheckForUpdates', {'repeat': -1})
261
+ endfunction
262
+
263
+ function! CheckForUpdates(timer)
264
+ let bufnum = bufnr(s:watched_dir . '/response.md')
265
+ if bufnum == -1
266
+ call timer_stop(s:monitor_timer)
267
+ return
268
+ endif
269
+ silent! checktime
270
+ endfunction
271
+
272
+ function! s:CustomInput(prompt) abort
273
+ let input = ''
274
+ let clean_prompt = a:prompt
275
+ let aborted = v:false
276
+ echohl Question
277
+ echon clean_prompt
278
+ echohl None
279
+ while 1
280
+ let c = getchar()
281
+ if type(c) == v:t_number
282
+ if c == 13 " Enter
283
+ break
284
+ elseif c == 27 " Esc
285
+ let aborted = v:true
286
+ break
287
+ elseif c == 8 || c == 127 " Backspace
288
+ if len(input) > 0
289
+ let input = input[:-2]
290
+ echon "\r" . clean_prompt . input . ' '
291
+ execute "normal! \<BS>"
292
+ endif
293
+ else
294
+ let char = nr2char(c)
295
+ let input .= char
296
+ echon char
297
+ endif
298
+ endif
299
+ endwhile
300
+ let input_length = aborted ? 0 : len(input)
301
+ let clear_length = len(clean_prompt) + input_length
302
+ echon "\r" . repeat(' ', clear_length) . "\r"
303
+ return aborted ? v:null : input
304
+ endfunction
305
+
306
+ function! SaveUserInput()
307
+ let user_input = s:CustomInput('Ask LLM: ')
308
+ if user_input is v:null
309
+ echo "Input aborted"
310
+ return
311
+ endif
312
+ let user_file = s:watched_dir . '/user'
313
+ call writefile([user_input], user_file, 'w')
314
+ let current_file = expand('%:p')
315
+ let tree_file = s:watched_dir . '/tree'
316
+ call writefile([current_file], tree_file, 'w')
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! FollowUpInput()
333
+ call writefile([], s:watched_dir . '/yank', 'w')
334
+ call writefile([], s:watched_dir . '/context', 'w')
335
+ call writefile([], s:watched_dir . '/followup', 'w')
336
+ call SaveUserInput()
337
+ endfunction
338
+
339
+ vnoremap <C-l> :w! $WATCH_DIR/yank<CR>:w! $WATCH_DIR/context<CR>:call SaveUserInput()<CR>
340
+ nnoremap <C-l> V:w! $WATCH_DIR/yank<CR>:w! $WATCH_DIR/context<CR>:call SaveUserInput()<CR>
341
+ nnoremap <C-r> :call FollowUpInput()<CR>
342
+
343
+ call Monitor()
344
+ """).safe_substitute(dict(WATCH_DIR=WATCH_DIR))
345
+
346
+ async def main():
347
+ parser = argparse.ArgumentParser(description="VimLM - LLM-powered Vim assistant")
348
+ parser.add_argument("vim_args", nargs=argparse.REMAINDER, help="Vim arguments")
349
+ args = parser.parse_args()
350
+ with tempfile.NamedTemporaryFile(mode='w', suffix='.vim', delete=False) as f:
351
+ f.write(VIMLMSCRIPT)
352
+ vim_script = f.name
353
+ vim_command = ["vim", "-c", f"source {vim_script}"]
354
+ if args.vim_args:
355
+ vim_command.extend(args.vim_args)
356
+ else:
357
+ vim_command.append('.tmp')
358
+ monitor_task = asyncio.create_task(monitor_directory())
359
+ vim_process = await asyncio.create_subprocess_exec(*vim_command)
360
+ await vim_process.wait()
361
+ monitor_task.cancel()
362
+ try:
363
+ await monitor_task
364
+ except asyncio.CancelledError:
365
+ pass
366
+
367
+ def run():
368
+ asyncio.run(main())
369
+
370
+ if __name__ == '__main__':
371
+ 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