loki-mode 7.41.4 → 7.42.0

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.
package/mcp/lsp_proxy.py CHANGED
@@ -9,8 +9,12 @@ language's LSP is not detected, tools targeting that language return a
9
9
  structured `{"error": ...}` payload but the server itself stays up.
10
10
 
11
11
  Architecture:
12
- - Stdlib only (json, subprocess, shutil, threading, os, pathlib,
13
- atexit, signal, time, urllib). NO new pip dependencies.
12
+ - Stdlib for the LSP wire + lifecycle (json, subprocess, shutil,
13
+ threading, os, pathlib, atexit, signal, time, urllib). The only
14
+ non-stdlib import is anyio (a transitive MCP-SDK dependency), used
15
+ solely for the threading bridge that keeps the async tool handlers
16
+ off the event loop; it is guarded so the module still imports on the
17
+ no-SDK degrade path.
14
18
  - Lazy lifecycle: an `LSPClient` is spawned on the first tool call
15
19
  that needs a given language. Spawn does the LSP `initialize`
16
20
  handshake and caches `didOpen` per file URI.
@@ -43,6 +47,7 @@ Usage:
43
47
  from __future__ import annotations
44
48
 
45
49
  import atexit
50
+ import functools
46
51
  import json
47
52
  import logging
48
53
  import os
@@ -54,9 +59,43 @@ import sys
54
59
  import threading
55
60
  import time
56
61
  from pathlib import Path
57
- from typing import Any, Dict, List, Optional, Tuple
62
+ from typing import Any, Callable, Dict, List, Optional, Tuple
58
63
  from urllib.parse import quote
59
64
 
65
+ # v7.x async-safety fix: the MCP SDK dispatches @mcp.tool() handlers
66
+ # concurrently on its anyio event loop via start_soon. Every handler in this
67
+ # module does blocking I/O (LSP handshake up to 10s, request waits up to 5s,
68
+ # diagnostics poll sleeps) with no await, so each one stalls the whole loop.
69
+ # We offload the blocking body to a worker thread via anyio.to_thread.run_sync.
70
+ #
71
+ # anyio is a transitive dependency of the MCP SDK, so it is present whenever
72
+ # the proxy is actually runnable. But this module must still IMPORT cleanly on
73
+ # the _NoopFastMCP degrade path (no SDK, possibly no anyio), so the import is
74
+ # guarded and `_to_thread` falls back to an inline call when anyio is absent.
75
+ # The inline fallback only runs in degrade/test envs that have no event loop
76
+ # to stall, so it is safe there.
77
+ try:
78
+ import anyio.to_thread as _anyio_to_thread # noqa: E402
79
+
80
+ _HAS_ANYIO = True
81
+ except ImportError: # pragma: no cover - degrade path without anyio
82
+ _anyio_to_thread = None # type: ignore[assignment]
83
+ _HAS_ANYIO = False
84
+
85
+
86
+ async def _to_thread(func: Callable[..., Any], *args: Any, **kwargs: Any) -> Any:
87
+ """Run a blocking callable off the event loop.
88
+
89
+ Uses anyio.to_thread.run_sync (the MCP SDK's own threading bridge) when
90
+ anyio is available; otherwise calls inline (degrade/test path, no loop to
91
+ stall). anyio's run_sync is positional-only, so kwargs are bound via
92
+ functools.partial before handing the callable to the worker thread."""
93
+ if _HAS_ANYIO and _anyio_to_thread is not None:
94
+ if kwargs:
95
+ func = functools.partial(func, **kwargs)
96
+ return await _anyio_to_thread.run_sync(func, *args)
97
+ return func(*args, **kwargs)
98
+
60
99
 
61
100
  # ============================================================
62
101
  # LOGGING (stderr; stdio transport reserves stdout for JSON-RPC)
@@ -249,8 +288,38 @@ class LSPClient:
249
288
  self.extra_args = list(extra_args)
250
289
  self.proc: Optional[subprocess.Popen] = None
251
290
  self._next_id = 1
252
- self._opened_uris: set = set()
291
+ # v7.x staleness fix: track per-URI document version + on-disk mtime
292
+ # (st_mtime_ns) instead of a plain "opened" set. On a second open of a
293
+ # file whose mtime changed, we send textDocument/didChange with an
294
+ # incremented version (full-sync) so the LSP re-analyzes the new
295
+ # content; without this, did_open early-returned and every query (and
296
+ # diagnostics) saw the first-open snapshot forever. mtime is used
297
+ # (not a content hash) because it is content-independent: it always
298
+ # bumps on save, sidestepping the same-size-edit blind spot that bit
299
+ # the non-git codebase signature (see task #569).
300
+ # _doc_versions: uri -> last LSP document version we sent
301
+ # _doc_mtimes: uri -> st_mtime_ns at the time we last (re)opened
302
+ self._doc_versions: Dict[str, int] = {}
303
+ self._doc_mtimes: Dict[str, int] = {}
253
304
  self._lock = threading.Lock()
305
+ # v7.x async-safety fix: with handlers now offloaded to worker threads
306
+ # (anyio.to_thread), two tool calls can run did_open()/request() in
307
+ # parallel. _write_lsp() does write(header)+write(body)+flush(); if two
308
+ # threads interleave there, the Content-Length framing on the LSP's
309
+ # stdin is corrupted. _write_lock serializes ONLY the stdin write
310
+ # sequences (and the did_open check-and-record), never the response
311
+ # wait or the diagnostics poll, so concurrency is preserved everywhere
312
+ # that matters.
313
+ self._write_lock = threading.Lock()
314
+ # v7.x async-safety fix: _next_request_id() is a read-modify-write on
315
+ # _next_id. With handlers offloaded to worker threads, two concurrent
316
+ # request() calls on the same-language client can allocate the SAME id
317
+ # (the GIL can switch between the load and the store), which collides
318
+ # in _response_queues -- one waiter is overwritten and hangs, or a
319
+ # response is routed to the wrong caller. This leaf lock (it never
320
+ # calls out while held, so no ordering concern with the other locks)
321
+ # makes id allocation atomic.
322
+ self._id_lock = threading.Lock()
254
323
  self._initialized = False
255
324
  # v7.7.14 LSP regression fix (was broken since v7.7.0):
256
325
  # publishDiagnostics notifications were dropped by request()'s
@@ -298,7 +367,9 @@ class LSPClient:
298
367
  self._response_queues.clear()
299
368
  with self._lock:
300
369
  self.pending_diagnostics.clear()
301
- self._opened_uris.clear()
370
+ with self._write_lock:
371
+ self._doc_versions.clear()
372
+ self._doc_mtimes.clear()
302
373
  self._initialized = False
303
374
  cmd = [self.binary_path] + self.extra_args
304
375
  self.proc = subprocess.Popen(
@@ -427,37 +498,93 @@ class LSPClient:
427
498
  pass
428
499
 
429
500
  def _next_request_id(self) -> int:
430
- rid = self._next_id
431
- self._next_id += 1
432
- return rid
501
+ # Atomic id allocation: see _id_lock note in __init__. Called from
502
+ # request() and shutdown(), which may run concurrently once handlers
503
+ # are offloaded to worker threads.
504
+ with self._id_lock:
505
+ rid = self._next_id
506
+ self._next_id += 1
507
+ return rid
433
508
 
434
509
  def did_open(self, file_path: str) -> None:
435
- """Send `textDocument/didOpen` for `file_path` if not already
436
- opened. Reads file contents from disk; silently no-ops if the
437
- file is unreadable (subsequent request will fail with a clean
438
- LSP error rather than crashing the proxy)."""
510
+ """Ensure the LSP has the CURRENT content of `file_path` open.
511
+
512
+ First call for a URI: sends `textDocument/didOpen` at version 1 and
513
+ records the file's on-disk mtime. Subsequent call for an already-open
514
+ URI whose on-disk mtime CHANGED since the last (re)open: sends
515
+ `textDocument/didChange` (full document sync) with an incremented
516
+ version so the server re-analyzes the edited file, and clears any
517
+ stale published diagnostics for that URI so the next poll waits for a
518
+ fresh publish instead of returning the pre-edit snapshot. Unchanged
519
+ files early-return (no redundant didChange), preserving the original
520
+ no-op optimization.
521
+
522
+ Reads file contents from disk; silently no-ops if the file is
523
+ unreadable (a subsequent request will fail with a clean LSP error
524
+ rather than crashing the proxy)."""
439
525
  uri = _path_to_uri(file_path)
440
- if uri in self._opened_uris:
526
+ try:
527
+ mtime = os.stat(file_path).st_mtime_ns
528
+ except OSError:
441
529
  return
530
+ # Fast path: already open and on-disk content unchanged since last
531
+ # (re)open. No write, no lock contention beyond the dict read.
532
+ with self._write_lock:
533
+ prev_mtime = self._doc_mtimes.get(uri)
534
+ already_open = uri in self._doc_versions
535
+ if already_open and prev_mtime == mtime:
536
+ return
442
537
  try:
443
538
  with open(file_path, 'r', encoding='utf-8', errors='replace') as fh:
444
539
  text = fh.read()
445
540
  except OSError:
446
541
  return
447
542
  lsp_id, _suffixes, _bin, _args = LANG_MAP[self.language]
448
- _write_lsp(self.proc.stdin, {
449
- 'jsonrpc': '2.0',
450
- 'method': 'textDocument/didOpen',
451
- 'params': {
452
- 'textDocument': {
453
- 'uri': uri,
454
- 'languageId': lsp_id,
455
- 'version': 1,
456
- 'text': text,
543
+ with self._write_lock:
544
+ # Re-read under the lock so two racing threads agree on state.
545
+ prev_version = self._doc_versions.get(uri)
546
+ if prev_version is None:
547
+ # First open of this URI.
548
+ _write_lsp(self.proc.stdin, {
549
+ 'jsonrpc': '2.0',
550
+ 'method': 'textDocument/didOpen',
551
+ 'params': {
552
+ 'textDocument': {
553
+ 'uri': uri,
554
+ 'languageId': lsp_id,
555
+ 'version': 1,
556
+ 'text': text,
557
+ },
558
+ },
559
+ })
560
+ self._doc_versions[uri] = 1
561
+ self._doc_mtimes[uri] = mtime
562
+ return
563
+ if self._doc_mtimes.get(uri) == mtime:
564
+ # Another thread already refreshed to this mtime; nothing to do.
565
+ return
566
+ # File changed on disk since we last opened it: full-sync
567
+ # didChange with an incremented version.
568
+ new_version = prev_version + 1
569
+ _write_lsp(self.proc.stdin, {
570
+ 'jsonrpc': '2.0',
571
+ 'method': 'textDocument/didChange',
572
+ 'params': {
573
+ 'textDocument': {
574
+ 'uri': uri,
575
+ 'version': new_version,
576
+ },
577
+ 'contentChanges': [{'text': text}],
457
578
  },
458
- },
459
- })
460
- self._opened_uris.add(uri)
579
+ })
580
+ self._doc_versions[uri] = new_version
581
+ self._doc_mtimes[uri] = mtime
582
+ # Drop the stale published diagnostics for this URI (outside the
583
+ # write lock, under _lock which guards pending_diagnostics) so that
584
+ # lsp_get_diagnostics polls for the re-published set instead of
585
+ # immediately returning the pre-edit snapshot.
586
+ with self._lock:
587
+ self.pending_diagnostics.pop(uri, None)
461
588
 
462
589
  def request(self, method: str, params: Dict[str, Any], timeout: float = 5.0) -> Dict[str, Any]:
463
590
  """Send a JSON-RPC request and block until its response arrives.
@@ -472,12 +599,17 @@ class LSPClient:
472
599
  self._response_queues[rid] = wait_q
473
600
  try:
474
601
  try:
475
- _write_lsp(self.proc.stdin, {
476
- 'jsonrpc': '2.0',
477
- 'id': rid,
478
- 'method': method,
479
- 'params': params,
480
- })
602
+ # Serialize the stdin write sequence against did_open() and
603
+ # other concurrent request() calls so the Content-Length
604
+ # framing is never interleaved. The lock is held ONLY for the
605
+ # write, never across the wait below.
606
+ with self._write_lock:
607
+ _write_lsp(self.proc.stdin, {
608
+ 'jsonrpc': '2.0',
609
+ 'id': rid,
610
+ 'method': method,
611
+ 'params': params,
612
+ })
481
613
  except (BrokenPipeError, OSError) as exc:
482
614
  return {'error': {'message': f'LSP I/O failure on write: {exc}'}}
483
615
  try:
@@ -498,7 +630,11 @@ class LSPClient:
498
630
  if not self.proc or self.proc.poll() is not None:
499
631
  return
500
632
  try:
501
- with self._lock:
633
+ # Serialize against concurrent request()/did_open() stdin writes
634
+ # via _write_lock so the shutdown + exit frames are not interleaved
635
+ # with another in-flight write. (Switched from _lock, which guards
636
+ # pending_diagnostics, to the dedicated stdin write lock.)
637
+ with self._write_lock:
502
638
  _write_lsp(self.proc.stdin, {
503
639
  'jsonrpc': '2.0',
504
640
  'id': self._next_request_id(),
@@ -560,7 +696,23 @@ def _get_or_spawn_client(language: str) -> Optional[LSPClient]:
560
696
  logger.warning("LSP spawn failed for %s: %s", language, exc)
561
697
  return None
562
698
  with _clients_lock:
563
- _clients[language] = client
699
+ # Double-check after re-acquiring: a concurrent first-call for the same
700
+ # language (now reachable because handlers run in worker threads via
701
+ # _to_thread) may have spawned and registered a live client while we
702
+ # were handshaking. If so, keep the cached one and shut down our
703
+ # duplicate so we never leak an orphaned LSP subprocess.
704
+ existing = _clients.get(language)
705
+ if existing is not None and existing.proc and existing.proc.poll() is None:
706
+ losing = client
707
+ client = existing
708
+ else:
709
+ losing = None
710
+ _clients[language] = client
711
+ if losing is not None:
712
+ try:
713
+ losing.shutdown()
714
+ except Exception as exc: # best-effort reap; never fail the caller
715
+ logger.warning("LSP duplicate-client shutdown failed for %s: %s", language, exc)
564
716
  return client
565
717
 
566
718
 
@@ -776,7 +928,8 @@ async def lsp_find_references(file: str, line: int, character: int,
776
928
  JSON-encoded string. Success: {"result": [...], "language": ...}.
777
929
  Error: {"error": "..."}.
778
930
  """
779
- result = _dispatch_lsp(
931
+ result = await _to_thread(
932
+ _dispatch_lsp,
780
933
  file, line, character, 'textDocument/references',
781
934
  extra_params={'context': {'includeDeclaration': bool(include_declaration)}},
782
935
  )
@@ -797,7 +950,8 @@ async def lsp_go_to_definition(file: str, line: int, character: int) -> str:
797
950
  JSON-encoded string with `result` (LSP Location | Location[] |
798
951
  LocationLink[]) on success or `error` on failure.
799
952
  """
800
- result = _dispatch_lsp(
953
+ result = await _to_thread(
954
+ _dispatch_lsp,
801
955
  file, line, character, 'textDocument/definition',
802
956
  )
803
957
  return json.dumps(result)
@@ -818,7 +972,8 @@ async def lsp_symbol_at_position(file: str, line: int, character: int) -> str:
818
972
  JSON-encoded string with `result` (LSP Hover) on success or
819
973
  `error` on failure.
820
974
  """
821
- result = _dispatch_lsp(
975
+ result = await _to_thread(
976
+ _dispatch_lsp,
822
977
  file, line, character, 'textDocument/hover',
823
978
  )
824
979
  return json.dumps(result)
@@ -894,30 +1049,11 @@ def _workspace_symbol_request(language: str, query: str, limit: int = 50) -> Dic
894
1049
  return {'result': result[:limit], 'language': language}
895
1050
 
896
1051
 
897
- @mcp.tool()
898
- async def lsp_check_exists(symbol: str, kind: Optional[str] = None,
899
- language: Optional[str] = None) -> str:
900
- """Cheap existence check for a symbol in the current workspace.
901
-
902
- The single most useful grounding primitive: an agent about to write
903
- `flightApi.getStatus()` should call `lsp_check_exists("getStatus")`
904
- first. If false, it means LSP could not find that name anywhere in
905
- the workspace; the agent should resolve via find / grep / read
906
- before writing the call.
907
-
908
- Args:
909
- symbol: Symbol name to look for (substring match per LSP spec).
910
- kind: Optional filter: 'function', 'class', 'method', 'variable',
911
- etc. If provided, only symbols whose LSP SymbolKind matches
912
- are counted.
913
- language: Optional language override. If None, auto-detected
914
- from workspace markers (package.json, requirements.txt, etc.).
915
-
916
- Returns:
917
- JSON-encoded string: {"exists": bool, "matches": N, "samples": [...],
918
- "language": "...", "elapsed_ms": float}. On no-LSP-available:
919
- {"error": "...", "exists": null}.
920
- """
1052
+ def _lsp_check_exists_blocking(symbol: str, kind: Optional[str] = None,
1053
+ language: Optional[str] = None) -> str:
1054
+ """Blocking implementation of lsp_check_exists. Runs on a worker thread
1055
+ via _to_thread so the spawn handshake + workspace/symbol request never
1056
+ stall the MCP event loop."""
921
1057
  import time as _t
922
1058
  t0 = _t.perf_counter()
923
1059
  if not symbol or not isinstance(symbol, str):
@@ -960,22 +1096,36 @@ async def lsp_check_exists(symbol: str, kind: Optional[str] = None,
960
1096
 
961
1097
 
962
1098
  @mcp.tool()
963
- async def lsp_get_diagnostics(file: str) -> str:
964
- """Return current LSP diagnostics (errors + warnings) for a file.
1099
+ async def lsp_check_exists(symbol: str, kind: Optional[str] = None,
1100
+ language: Optional[str] = None) -> str:
1101
+ """Cheap existence check for a symbol in the current workspace.
965
1102
 
966
- Diagnostics are published asynchronously by LSP servers via
967
- `textDocument/publishDiagnostics`. This tool opens the file (if not
968
- already open) and waits up to 1 second for diagnostics to arrive,
969
- then returns whatever has been published.
1103
+ The single most useful grounding primitive: an agent about to write
1104
+ `flightApi.getStatus()` should call `lsp_check_exists("getStatus")`
1105
+ first. If false, it means LSP could not find that name anywhere in
1106
+ the workspace; the agent should resolve via find / grep / read
1107
+ before writing the call.
970
1108
 
971
1109
  Args:
972
- file: Absolute or cwd-relative path to the source file.
1110
+ symbol: Symbol name to look for (substring match per LSP spec).
1111
+ kind: Optional filter: 'function', 'class', 'method', 'variable',
1112
+ etc. If provided, only symbols whose LSP SymbolKind matches
1113
+ are counted.
1114
+ language: Optional language override. If None, auto-detected
1115
+ from workspace markers (package.json, requirements.txt, etc.).
973
1116
 
974
1117
  Returns:
975
- JSON: {"diagnostics": [{severity, message, range, source}, ...],
976
- "count_errors": N, "count_warnings": M, "language": "...",
977
- "elapsed_ms": float}.
1118
+ JSON-encoded string: {"exists": bool, "matches": N, "samples": [...],
1119
+ "language": "...", "elapsed_ms": float}. On no-LSP-available:
1120
+ {"error": "...", "exists": null}.
978
1121
  """
1122
+ return await _to_thread(_lsp_check_exists_blocking, symbol, kind, language)
1123
+
1124
+
1125
+ def _lsp_get_diagnostics_blocking(file: str) -> str:
1126
+ """Blocking implementation of lsp_get_diagnostics. Runs on a worker
1127
+ thread via _to_thread; the spawn handshake, didOpen/didChange and the
1128
+ ~1s diagnostics poll (time.sleep loop) must not stall the event loop."""
979
1129
  import time as _t
980
1130
  t0 = _t.perf_counter()
981
1131
  abs_file = os.path.abspath(file)
@@ -1020,24 +1170,30 @@ async def lsp_get_diagnostics(file: str) -> str:
1020
1170
 
1021
1171
 
1022
1172
  @mcp.tool()
1023
- async def lsp_workspace_symbols(query: str, limit: int = 20,
1024
- language: Optional[str] = None) -> str:
1025
- """Fuzzy-search symbols across the entire workspace.
1173
+ async def lsp_get_diagnostics(file: str) -> str:
1174
+ """Return current LSP diagnostics (errors + warnings) for a file.
1026
1175
 
1027
- Use when an agent is hunting for the right name (knows the
1028
- function/class is about "config loading" but isn't sure of the
1029
- actual identifier). Returns LSP workspace/symbol results scoped to
1030
- the detected language (or the language override).
1176
+ Diagnostics are published asynchronously by LSP servers via
1177
+ `textDocument/publishDiagnostics`. This tool opens the file (if not
1178
+ already open, or re-syncs it via didChange if edited since first open)
1179
+ and waits up to 1 second for diagnostics to arrive, then returns
1180
+ whatever has been published.
1031
1181
 
1032
1182
  Args:
1033
- query: Symbol query (substring or fuzzy per LSP server impl).
1034
- limit: Max results to return (default 20, hard cap 100).
1035
- language: Optional language override.
1183
+ file: Absolute or cwd-relative path to the source file.
1036
1184
 
1037
1185
  Returns:
1038
- JSON: {"matches": [...], "count": N, "language": "...",
1186
+ JSON: {"diagnostics": [{severity, message, range, source}, ...],
1187
+ "count_errors": N, "count_warnings": M, "language": "...",
1039
1188
  "elapsed_ms": float}.
1040
1189
  """
1190
+ return await _to_thread(_lsp_get_diagnostics_blocking, file)
1191
+
1192
+
1193
+ def _lsp_workspace_symbols_blocking(query: str, limit: int = 20,
1194
+ language: Optional[str] = None) -> str:
1195
+ """Blocking implementation of lsp_workspace_symbols. Runs on a worker
1196
+ thread via _to_thread (spawn handshake + workspace/symbol request)."""
1041
1197
  import time as _t
1042
1198
  t0 = _t.perf_counter()
1043
1199
  if not isinstance(query, str):
@@ -1070,20 +1226,31 @@ async def lsp_workspace_symbols(query: str, limit: int = 20,
1070
1226
 
1071
1227
 
1072
1228
  @mcp.tool()
1073
- async def lsp_find_definition_by_name(symbol: str,
1074
- language: Optional[str] = None) -> str:
1075
- """Find where a named symbol is defined, without needing a file
1076
- position upfront. Convenience wrapper: runs workspace/symbol then
1077
- returns the first result's location.
1229
+ async def lsp_workspace_symbols(query: str, limit: int = 20,
1230
+ language: Optional[str] = None) -> str:
1231
+ """Fuzzy-search symbols across the entire workspace.
1232
+
1233
+ Use when an agent is hunting for the right name (knows the
1234
+ function/class is about "config loading" but isn't sure of the
1235
+ actual identifier). Returns LSP workspace/symbol results scoped to
1236
+ the detected language (or the language override).
1078
1237
 
1079
1238
  Args:
1080
- symbol: Symbol name to find.
1239
+ query: Symbol query (substring or fuzzy per LSP server impl).
1240
+ limit: Max results to return (default 20, hard cap 100).
1081
1241
  language: Optional language override.
1082
1242
 
1083
1243
  Returns:
1084
- JSON: {"location": {uri, range} | null, "name": str | null,
1085
- "language": "...", "elapsed_ms": float}.
1244
+ JSON: {"matches": [...], "count": N, "language": "...",
1245
+ "elapsed_ms": float}.
1086
1246
  """
1247
+ return await _to_thread(_lsp_workspace_symbols_blocking, query, limit, language)
1248
+
1249
+
1250
+ def _lsp_find_definition_by_name_blocking(symbol: str,
1251
+ language: Optional[str] = None) -> str:
1252
+ """Blocking implementation of lsp_find_definition_by_name. Runs on a
1253
+ worker thread via _to_thread (spawn handshake + workspace/symbol)."""
1087
1254
  import time as _t
1088
1255
  t0 = _t.perf_counter()
1089
1256
  root = _resolve_workspace_root()
@@ -1115,6 +1282,24 @@ async def lsp_find_definition_by_name(symbol: str,
1115
1282
  })
1116
1283
 
1117
1284
 
1285
+ @mcp.tool()
1286
+ async def lsp_find_definition_by_name(symbol: str,
1287
+ language: Optional[str] = None) -> str:
1288
+ """Find where a named symbol is defined, without needing a file
1289
+ position upfront. Convenience wrapper: runs workspace/symbol then
1290
+ returns the first result's location.
1291
+
1292
+ Args:
1293
+ symbol: Symbol name to find.
1294
+ language: Optional language override.
1295
+
1296
+ Returns:
1297
+ JSON: {"location": {uri, range} | null, "name": str | null,
1298
+ "language": "...", "elapsed_ms": float}.
1299
+ """
1300
+ return await _to_thread(_lsp_find_definition_by_name_blocking, symbol, language)
1301
+
1302
+
1118
1303
  # ============================================================
1119
1304
  # MAIN
1120
1305
  # ============================================================
package/memory/engine.py CHANGED
@@ -242,6 +242,8 @@ class MemoryEngine:
242
242
  patterns_data = self.storage.read_json("semantic/patterns.json") or {}
243
243
  referenced_ids: set = set()
244
244
  for pattern in patterns_data.get("patterns", []):
245
+ if not isinstance(pattern, dict):
246
+ continue
245
247
  referenced_ids.update(pattern.get("source_episodes", []))
246
248
 
247
249
  # Scan episodic directories
@@ -366,11 +368,15 @@ class MemoryEngine:
366
368
  })
367
369
  index["total_memories"] = index.get("total_memories", 0) + 1
368
370
  else:
371
+ # Only count a given episode once. On resume/checkpoint the same
372
+ # trace id can be re-saved; without this guard episode_count,
373
+ # total_cost_usd, and total_tokens would inflate on every re-save
374
+ # even though episode_ids is already de-duplicated.
369
375
  if episode_id and episode_id not in found.get("episode_ids", []):
370
376
  found.setdefault("episode_ids", []).append(episode_id)
371
- found["episode_count"] = found.get("episode_count", 0) + 1
372
- found["total_cost_usd"] = float(found.get("total_cost_usd", 0) or 0) + cost
373
- found["total_tokens"] = int(found.get("total_tokens", 0) or 0) + tokens
377
+ found["episode_count"] = found.get("episode_count", 0) + 1
378
+ found["total_cost_usd"] = float(found.get("total_cost_usd", 0) or 0) + cost
379
+ found["total_tokens"] = int(found.get("total_tokens", 0) or 0) + tokens
374
380
  merged = set(found.get("files_touched", []) or []) | set(files[:20])
375
381
  found["files_touched"] = sorted(merged)[:50]
376
382
  found["last_accessed"] = now
@@ -489,6 +495,8 @@ class MemoryEngine:
489
495
  """
490
496
  patterns_data = self.storage.read_json("semantic/patterns.json") or {}
491
497
  for pattern in patterns_data.get("patterns", []):
498
+ if not isinstance(pattern, dict):
499
+ continue
492
500
  if pattern.get("id") == pattern_id:
493
501
  return self._dict_to_pattern(pattern)
494
502
  return None
@@ -512,6 +520,8 @@ class MemoryEngine:
512
520
  results: List[SemanticPattern] = []
513
521
 
514
522
  for pattern in patterns_data.get("patterns", []):
523
+ if not isinstance(pattern, dict):
524
+ continue
515
525
  # Filter by confidence
516
526
  if pattern.get("confidence", 0) < min_confidence:
517
527
  continue
@@ -837,6 +847,8 @@ class MemoryEngine:
837
847
  # Index semantic patterns
838
848
  patterns_data = self.storage.read_json("semantic/patterns.json") or {}
839
849
  for pattern in patterns_data.get("patterns", []):
850
+ if not isinstance(pattern, dict):
851
+ continue
840
852
  total_memories += 1
841
853
  category = pattern.get("category", "general")
842
854
 
package/memory/storage.py CHANGED
@@ -617,6 +617,12 @@ class MemoryStorage:
617
617
  "patterns": []
618
618
  }
619
619
 
620
+ # Defensive: a pre-existing patterns.json that is valid JSON but
621
+ # lacks the "patterns" key (partial/external write, alternate
622
+ # schema, or a {"version": ...}-only file) would otherwise raise
623
+ # KeyError below and silently lose the save. Ensure the list exists.
624
+ patterns_file.setdefault("patterns", [])
625
+
620
626
  # Upsert: update existing pattern or append new
621
627
  existing_idx = None
622
628
  for i, p in enumerate(patterns_file["patterns"]):
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "loki-mode",
3
3
  "mcpName": "io.github.asklokesh/loki-mode",
4
- "version": "7.41.4",
4
+ "version": "7.42.0",
5
5
  "description": "Loki Mode by Autonomi. Autonomous spec-to-product system: takes a PRD, GitHub issue, OpenAPI/JSON/YAML, or one-line brief to a deployed app via the RARV-C closure loop with 11 quality gates. Provider-agnostic (Claude Code, OpenAI Codex, Cline, Aider).",
6
6
  "keywords": [
7
7
  "agent",
@@ -2,7 +2,7 @@
2
2
  "$schema": "https://json.schemastore.org/claude-code-plugin-manifest.json",
3
3
  "name": "loki-mode",
4
4
  "displayName": "Loki Mode",
5
- "version": "7.41.4",
5
+ "version": "7.42.0",
6
6
  "description": "Autonomous spec-to-product build system with a built-in trust layer (RARV-C closure loop, 11 quality gates, completion council). Ships Loki's spec-hardening, drift-detection, and deterministic PR verification commands plus the Loki MCP server.",
7
7
  "author": {
8
8
  "name": "Autonomi",
@@ -74,6 +74,13 @@ Every iteration follows this cycle:
74
74
 
75
75
  The RARV cycle now closes with an explicit Critique step (RARV-C). After VERIFY, an override council of real provider judges (v7.5.4) issues a binding decision before the iteration is marked complete. See `references/quality-control.md` for the override council protocol.
76
76
 
77
+ ### Verified Completion: Evidence Required (v7.41.1, v7.41.5)
78
+
79
+ Completion is gated on affirmative test evidence, not the absence of a detected failure.
80
+
81
+ - **Test evidence captured before the gate reads it (v7.41.1).** Loki runs the project's own tests and persists `.loki/quality/test-results.json` before the completion evidence gate evaluates it, so absent test evidence can no longer silently pass the test axis. Default-on; opt out with `LOKI_COMPLETION_TEST_CAPTURE=0`. It reuses the quality-ladder run (no double test execution per iteration) and a project with no runner records `{"runner":"none","pass":true}`. Source: `autonomy/run.sh` (`ensure_completion_test_evidence`, `:7236`).
82
+ - **Completion council heuristic fallback defaults to CONTINUE (v7.41.5).** When no AI provider is available for the council, the heuristic member evaluation starts each vote at CONTINUE and flips to COMPLETE only when no failure is detected AND affirmative positive evidence is present (the same non-red `test-results.json` signal the evidence hard gate uses). An empty `.loki/` with no test evidence no longer clears the threshold on "absence of failure". Legitimate finished projects (passing or genuinely no-test) still vote COMPLETE. Source: `autonomy/completion-council.sh` (`council_evaluate_member`, `:2044`-`:2063`, `:2127`-`:2140`).
83
+
77
84
  ---
78
85
 
79
86
  ## CONTINUITY.md - Working Memory Protocol
@@ -287,6 +287,12 @@ Task(subagent_type="general-purpose", model="opus",
287
287
  - ALWAYS re-run ALL 3 reviewers after fixes (not just the one that found the issue)
288
288
  - Wait for all reviews to complete before aggregating results
289
289
 
290
+ ### Inconclusive Reviews Block (v7.41.1)
291
+
292
+ A code-review round must produce real verdicts to pass. `run_code_review` counts only reviewer outputs that exist, are non-empty, and carry a recognized `VERDICT:` line. If every reviewer returns no usable verdict (all NO_OUTPUT or unparseable), the round is treated as INCONCLUSIVE and BLOCKS rather than silently passing with zero findings. A bounded one-shot retry runs first; the block is opt-out via `LOKI_REVIEW_INCONCLUSIVE_BLOCK=0` (records, never blocks). APPROVE / PASS-with-concerns still pass.
293
+
294
+ The reviewer-prompt diff excludes `.loki/` and `.git/` via git pathspec (`git diff <sha>..HEAD -- . ':(exclude).loki/'`). This mirrors the completion evidence gate and prevents a `.loki/`-tracked repo from ballooning the diff to the point that the reviewer model overflows and returns empty (the original NO_OUTPUT cause). Source: `autonomy/run.sh` (`run_code_review`, diff at `:2578`, inconclusive handling at `:8134`-`:8270`).
295
+
290
296
  ---
291
297
 
292
298
  ## Structured Prompting for Subagents
package/skills/agents.md CHANGED
@@ -135,6 +135,7 @@ Task(
135
135
  - WAIT for all 3 before aggregating
136
136
  - IF unanimous PASS: run Devil's Advocate reviewer (anti-sycophancy)
137
137
  - Critical/High = BLOCK, Medium = TODO, Low = informational
138
+ - IF every reviewer returns no usable verdict (all NO_OUTPUT / unparseable): the round is INCONCLUSIVE and BLOCKS, never silently passes (v7.41.1, bounded retry first; opt out `LOKI_REVIEW_INCONCLUSIVE_BLOCK=0`). The reviewer diff excludes `.loki/` and `.git/` so a tracked `.loki/` cannot overflow the prompt into the empty-output that caused the original silent pass. See `skills/quality-gates.md` for the env knobs.
138
139
 
139
140
  ---
140
141