loki-mode 7.5.17 → 7.5.28
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/README.md +10 -9
- package/SKILL.md +14 -14
- package/VERSION +1 -1
- package/autonomy/completion-council.sh +26 -3
- package/autonomy/lib/claude-flags.sh +132 -0
- package/autonomy/lib/mcp-config.sh +160 -0
- package/autonomy/lib/project-graph.sh +685 -0
- package/autonomy/lib/voter-agents.sh +356 -0
- package/autonomy/loki +108 -111
- package/autonomy/run.sh +95 -186
- package/bin/loki +12 -1
- package/dashboard/__init__.py +1 -1
- package/dashboard/requirements.txt +13 -8
- package/dashboard/server.py +33 -15
- package/dashboard/static/index.html +298 -299
- package/docs/INSTALLATION.md +54 -21
- package/docs/retrospectives/v7.5.15-fleet-postmortem.md +325 -0
- package/docs/retrospectives/v7.5.15-honesty-audit.md +136 -0
- package/docs/retrospectives/v7.5.15-llm-failure-modes.md +49 -0
- package/loki-ts/data/finding-schema.json +74 -0
- package/loki-ts/data/model-pricing.json +12 -0
- package/loki-ts/dist/loki.js +198 -172
- package/mcp/__init__.py +1 -1
- package/mcp/lsp_proxy.py +713 -0
- package/mcp/requirements.txt +9 -3
- package/mcp/tests/__init__.py +0 -0
- package/mcp/tests/test_lsp_proxy.py +377 -0
- package/memory/app_graph.py +153 -0
- package/memory/storage.py +6 -1
- package/memory/tests/test_app_graph.py +134 -0
- package/package.json +4 -3
- package/providers/claude.sh +115 -4
- package/providers/codex.sh +2 -2
- package/providers/loader.sh +4 -4
- package/providers/model_catalog.json +0 -9
- package/providers/models.sh +1 -2
- package/references/multi-provider.md +26 -35
- package/references/prompt-repetition.md +1 -1
- package/references/quality-control.md +1 -1
- package/skills/00-index.md +3 -3
- package/skills/model-selection.md +11 -14
- package/skills/providers.md +17 -57
- package/skills/quality-gates.md +2 -2
- package/skills/troubleshooting.md +1 -1
- package/src/integrations/github/action-handler.js +3 -2
- package/src/protocols/tools/start-project.js +1 -1
- package/providers/gemini.sh +0 -343
package/mcp/requirements.txt
CHANGED
|
@@ -1,3 +1,9 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
1
|
+
# Loki Mode MCP server dependencies.
|
|
2
|
+
# Phase N (v7.5.27): added upper bounds to cap risk of a future major-version
|
|
3
|
+
# break installing on a fresh `pip install`. Tested locally against the upper
|
|
4
|
+
# end of the installed range on this Mac:
|
|
5
|
+
# mcp 1.27.0, chromadb 1.5.1, anthropic 0.40+
|
|
6
|
+
# Update upper bound when a new tested major drops.
|
|
7
|
+
mcp>=1.0.0,<2.0.0
|
|
8
|
+
chromadb>=1.0.0,<2.0.0
|
|
9
|
+
anthropic>=0.40,<1.0.0
|
|
File without changes
|
|
@@ -0,0 +1,377 @@
|
|
|
1
|
+
"""
|
|
2
|
+
mcp/tests/test_lsp_proxy.py
|
|
3
|
+
|
|
4
|
+
Unit tests for the LSP proxy MCP server. Runs against fake LSP
|
|
5
|
+
subprocesses (no real typescript-language-server etc. required).
|
|
6
|
+
|
|
7
|
+
Verifies:
|
|
8
|
+
- _detect_lsps() returns a dict with the right shape (and empty when
|
|
9
|
+
no binaries are installed)
|
|
10
|
+
- LANG_MAP covers all 4 expected file suffix classes
|
|
11
|
+
- Tool dispatch returns {"error": "..."} when no language can be
|
|
12
|
+
detected for the file or when the language binary is absent
|
|
13
|
+
- Tool dispatch routes to the correct language client based on file
|
|
14
|
+
suffix
|
|
15
|
+
- Content-Length framing is written and parsed correctly
|
|
16
|
+
- atexit cleanup function calls shutdown on every spawned client and
|
|
17
|
+
clears the PID file
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import asyncio
|
|
23
|
+
import io
|
|
24
|
+
import json
|
|
25
|
+
import os
|
|
26
|
+
import sys
|
|
27
|
+
import tempfile
|
|
28
|
+
import unittest
|
|
29
|
+
from pathlib import Path
|
|
30
|
+
from unittest import mock
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
# Make the repo root importable so `import mcp.lsp_proxy` works regardless
|
|
34
|
+
# of where pytest / unittest is invoked from.
|
|
35
|
+
_REPO_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), '..', '..'))
|
|
36
|
+
if _REPO_ROOT not in sys.path:
|
|
37
|
+
sys.path.insert(0, _REPO_ROOT)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _run(coro):
|
|
41
|
+
return asyncio.run(coro)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _import_lsp_proxy():
|
|
45
|
+
"""Force a fresh import so module-level FastMCP loading runs each
|
|
46
|
+
time. Necessary because tests may install/remove subprocess mocks
|
|
47
|
+
between runs."""
|
|
48
|
+
for k in list(sys.modules.keys()):
|
|
49
|
+
if k == 'mcp.lsp_proxy':
|
|
50
|
+
del sys.modules[k]
|
|
51
|
+
from mcp import lsp_proxy as _lp # type: ignore
|
|
52
|
+
return _lp
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class LangMapAndDetectionTests(unittest.TestCase):
|
|
56
|
+
def setUp(self):
|
|
57
|
+
self.lp = _import_lsp_proxy()
|
|
58
|
+
self.lp._reset_detection_cache()
|
|
59
|
+
|
|
60
|
+
def test_lang_map_covers_all_4_target_languages(self):
|
|
61
|
+
suffixes_to_lang = {}
|
|
62
|
+
for lang, (_lsp_id, suffixes, _bin, _args) in self.lp.LANG_MAP.items():
|
|
63
|
+
for s in suffixes:
|
|
64
|
+
suffixes_to_lang[s] = lang
|
|
65
|
+
# 4 language classes per spec: TS/JS family, Python, Go, Rust.
|
|
66
|
+
self.assertIn('.ts', suffixes_to_lang)
|
|
67
|
+
self.assertIn('.tsx', suffixes_to_lang)
|
|
68
|
+
self.assertIn('.js', suffixes_to_lang)
|
|
69
|
+
self.assertIn('.jsx', suffixes_to_lang)
|
|
70
|
+
self.assertIn('.py', suffixes_to_lang)
|
|
71
|
+
self.assertIn('.go', suffixes_to_lang)
|
|
72
|
+
self.assertIn('.rs', suffixes_to_lang)
|
|
73
|
+
|
|
74
|
+
def test_binary_mapping_correct_per_spec(self):
|
|
75
|
+
# TS/TSX/JS/JSX -> typescript-language-server
|
|
76
|
+
for lang in ('typescript', 'javascript'):
|
|
77
|
+
self.assertEqual(self.lp.LANG_MAP[lang][2], 'typescript-language-server')
|
|
78
|
+
# .py -> pylsp
|
|
79
|
+
self.assertEqual(self.lp.LANG_MAP['python'][2], 'pylsp')
|
|
80
|
+
# .go -> gopls
|
|
81
|
+
self.assertEqual(self.lp.LANG_MAP['go'][2], 'gopls')
|
|
82
|
+
# .rs -> rust-analyzer
|
|
83
|
+
self.assertEqual(self.lp.LANG_MAP['rust'][2], 'rust-analyzer')
|
|
84
|
+
|
|
85
|
+
def test_detect_lsps_returns_dict_no_error_when_nothing_present(self):
|
|
86
|
+
with mock.patch.object(self.lp.shutil, 'which', return_value=None):
|
|
87
|
+
self.lp._reset_detection_cache()
|
|
88
|
+
result = self.lp._detect_lsps()
|
|
89
|
+
self.assertIsInstance(result, dict)
|
|
90
|
+
self.assertEqual(result, {})
|
|
91
|
+
|
|
92
|
+
def test_detect_lsps_finds_present_binaries(self):
|
|
93
|
+
# Pretend gopls and pylsp are installed.
|
|
94
|
+
def fake_which(name):
|
|
95
|
+
return {'gopls': '/usr/local/bin/gopls',
|
|
96
|
+
'pylsp': '/opt/homebrew/bin/pylsp'}.get(name)
|
|
97
|
+
with mock.patch.object(self.lp.shutil, 'which', side_effect=fake_which):
|
|
98
|
+
self.lp._reset_detection_cache()
|
|
99
|
+
result = self.lp._detect_lsps()
|
|
100
|
+
self.assertIn('go', result)
|
|
101
|
+
self.assertIn('python', result)
|
|
102
|
+
self.assertEqual(result['go'], '/usr/local/bin/gopls')
|
|
103
|
+
self.assertEqual(result['python'], '/opt/homebrew/bin/pylsp')
|
|
104
|
+
self.assertNotIn('rust', result)
|
|
105
|
+
self.assertNotIn('typescript', result)
|
|
106
|
+
|
|
107
|
+
def test_detection_result_is_cached(self):
|
|
108
|
+
call_count = {'n': 0}
|
|
109
|
+
|
|
110
|
+
def fake_which(name):
|
|
111
|
+
call_count['n'] += 1
|
|
112
|
+
return None
|
|
113
|
+
|
|
114
|
+
with mock.patch.object(self.lp.shutil, 'which', side_effect=fake_which):
|
|
115
|
+
self.lp._reset_detection_cache()
|
|
116
|
+
self.lp._detect_lsps()
|
|
117
|
+
first_calls = call_count['n']
|
|
118
|
+
self.lp._detect_lsps()
|
|
119
|
+
self.lp._detect_lsps()
|
|
120
|
+
self.assertEqual(call_count['n'], first_calls,
|
|
121
|
+
"Detection should be cached after first call")
|
|
122
|
+
|
|
123
|
+
def test_suffix_to_language(self):
|
|
124
|
+
self.assertEqual(self.lp._suffix_to_language('foo.ts'), 'typescript')
|
|
125
|
+
self.assertEqual(self.lp._suffix_to_language('foo.tsx'), 'typescript')
|
|
126
|
+
self.assertEqual(self.lp._suffix_to_language('foo.js'), 'javascript')
|
|
127
|
+
self.assertEqual(self.lp._suffix_to_language('foo.py'), 'python')
|
|
128
|
+
self.assertEqual(self.lp._suffix_to_language('foo.go'), 'go')
|
|
129
|
+
self.assertEqual(self.lp._suffix_to_language('foo.rs'), 'rust')
|
|
130
|
+
self.assertEqual(self.lp._suffix_to_language('foo.txt'), None)
|
|
131
|
+
self.assertEqual(self.lp._suffix_to_language('Makefile'), None)
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
class WireFramingTests(unittest.TestCase):
|
|
135
|
+
"""Verify Content-Length framing is correct (LSP spec, NOT line-
|
|
136
|
+
delimited JSON)."""
|
|
137
|
+
|
|
138
|
+
def setUp(self):
|
|
139
|
+
self.lp = _import_lsp_proxy()
|
|
140
|
+
|
|
141
|
+
def test_write_lsp_emits_content_length_header(self):
|
|
142
|
+
sink = io.BytesIO()
|
|
143
|
+
# BytesIO already implements .write(bytes) and .flush() (no-op).
|
|
144
|
+
msg = {'jsonrpc': '2.0', 'id': 1, 'method': 'foo', 'params': {}}
|
|
145
|
+
self.lp._write_lsp(sink, msg)
|
|
146
|
+
data = sink.getvalue()
|
|
147
|
+
self.assertTrue(data.startswith(b'Content-Length: '))
|
|
148
|
+
self.assertIn(b'\r\n\r\n', data)
|
|
149
|
+
header, _, body = data.partition(b'\r\n\r\n')
|
|
150
|
+
body_len = int(header.split(b':')[1].strip())
|
|
151
|
+
self.assertEqual(len(body), body_len)
|
|
152
|
+
self.assertEqual(json.loads(body), msg)
|
|
153
|
+
|
|
154
|
+
def test_read_lsp_roundtrip(self):
|
|
155
|
+
msg_in = {'jsonrpc': '2.0', 'id': 7,
|
|
156
|
+
'result': {'value': 'ok', 'nested': [1, 2, 3]}}
|
|
157
|
+
body = json.dumps(msg_in).encode('utf-8')
|
|
158
|
+
header = f'Content-Length: {len(body)}\r\n\r\n'.encode('ascii')
|
|
159
|
+
source = io.BytesIO(header + body)
|
|
160
|
+
msg_out = self.lp._read_lsp(source)
|
|
161
|
+
self.assertEqual(msg_out, msg_in)
|
|
162
|
+
|
|
163
|
+
def test_read_lsp_tolerates_extra_headers(self):
|
|
164
|
+
msg_in = {'jsonrpc': '2.0', 'id': 8, 'result': 'ok'}
|
|
165
|
+
body = json.dumps(msg_in).encode('utf-8')
|
|
166
|
+
wire = (
|
|
167
|
+
f'Content-Length: {len(body)}\r\n'
|
|
168
|
+
f'Content-Type: application/vscode-jsonrpc; charset=utf-8\r\n'
|
|
169
|
+
f'\r\n'
|
|
170
|
+
).encode('ascii') + body
|
|
171
|
+
msg_out = self.lp._read_lsp(io.BytesIO(wire))
|
|
172
|
+
self.assertEqual(msg_out, msg_in)
|
|
173
|
+
|
|
174
|
+
def test_read_lsp_returns_none_on_eof(self):
|
|
175
|
+
self.assertIsNone(self.lp._read_lsp(io.BytesIO(b'')))
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
class ToolDispatchTests(unittest.TestCase):
|
|
179
|
+
"""Verify the three MCP tools route correctly and return structured
|
|
180
|
+
error shapes on missing-binary / unsupported-suffix."""
|
|
181
|
+
|
|
182
|
+
def setUp(self):
|
|
183
|
+
self.lp = _import_lsp_proxy()
|
|
184
|
+
self.lp._reset_detection_cache()
|
|
185
|
+
# Wipe any clients carried over from prior tests / module reuse.
|
|
186
|
+
with self.lp._clients_lock:
|
|
187
|
+
self.lp._clients.clear()
|
|
188
|
+
|
|
189
|
+
def tearDown(self):
|
|
190
|
+
with self.lp._clients_lock:
|
|
191
|
+
self.lp._clients.clear()
|
|
192
|
+
|
|
193
|
+
def test_unsupported_file_suffix_returns_error(self):
|
|
194
|
+
raw = _run(self.lp.lsp_find_references('README.md', 0, 0))
|
|
195
|
+
body = json.loads(raw)
|
|
196
|
+
self.assertIn('error', body)
|
|
197
|
+
self.assertIn('Unsupported', body['error'])
|
|
198
|
+
|
|
199
|
+
def test_missing_language_binary_returns_error(self):
|
|
200
|
+
# No binaries installed.
|
|
201
|
+
with mock.patch.object(self.lp.shutil, 'which', return_value=None):
|
|
202
|
+
self.lp._reset_detection_cache()
|
|
203
|
+
raw_ref = _run(self.lp.lsp_find_references('foo.ts', 0, 0))
|
|
204
|
+
raw_def = _run(self.lp.lsp_go_to_definition('foo.py', 1, 2))
|
|
205
|
+
raw_sym = _run(self.lp.lsp_symbol_at_position('foo.go', 3, 4))
|
|
206
|
+
for raw in (raw_ref, raw_def, raw_sym):
|
|
207
|
+
body = json.loads(raw)
|
|
208
|
+
self.assertIn('error', body)
|
|
209
|
+
self.assertIn('No LSP detected', body['error'])
|
|
210
|
+
|
|
211
|
+
def test_dispatch_routes_to_correct_language(self):
|
|
212
|
+
# All three of these are different languages -> different clients
|
|
213
|
+
# are spawned. Mock _get_or_spawn_client to record the language
|
|
214
|
+
# it was asked for; return a fake client that always returns
|
|
215
|
+
# `{"result": "ok"}`.
|
|
216
|
+
called_languages = []
|
|
217
|
+
fake_client = mock.MagicMock()
|
|
218
|
+
fake_client.did_open.return_value = None
|
|
219
|
+
fake_client.request.return_value = {'result': 'ok'}
|
|
220
|
+
|
|
221
|
+
def fake_get_or_spawn(language):
|
|
222
|
+
called_languages.append(language)
|
|
223
|
+
return fake_client
|
|
224
|
+
|
|
225
|
+
with mock.patch.object(self.lp, '_get_or_spawn_client', side_effect=fake_get_or_spawn):
|
|
226
|
+
_run(self.lp.lsp_find_references('a.ts', 0, 0))
|
|
227
|
+
_run(self.lp.lsp_go_to_definition('b.py', 0, 0))
|
|
228
|
+
_run(self.lp.lsp_symbol_at_position('c.go', 0, 0))
|
|
229
|
+
_run(self.lp.lsp_find_references('d.rs', 0, 0))
|
|
230
|
+
_run(self.lp.lsp_find_references('e.tsx', 0, 0))
|
|
231
|
+
_run(self.lp.lsp_find_references('f.jsx', 0, 0))
|
|
232
|
+
self.assertEqual(called_languages,
|
|
233
|
+
['typescript', 'python', 'go', 'rust', 'typescript', 'javascript'])
|
|
234
|
+
|
|
235
|
+
def test_dispatch_uses_correct_lsp_methods(self):
|
|
236
|
+
method_calls = []
|
|
237
|
+
fake_client = mock.MagicMock()
|
|
238
|
+
fake_client.did_open.return_value = None
|
|
239
|
+
|
|
240
|
+
def record_request(method, params, timeout=5.0):
|
|
241
|
+
method_calls.append((method, params))
|
|
242
|
+
return {'result': 'ok'}
|
|
243
|
+
|
|
244
|
+
fake_client.request.side_effect = record_request
|
|
245
|
+
|
|
246
|
+
with mock.patch.object(self.lp, '_get_or_spawn_client', return_value=fake_client):
|
|
247
|
+
_run(self.lp.lsp_find_references('a.ts', 5, 3, include_declaration=True))
|
|
248
|
+
_run(self.lp.lsp_go_to_definition('a.ts', 5, 3))
|
|
249
|
+
_run(self.lp.lsp_symbol_at_position('a.ts', 5, 3))
|
|
250
|
+
methods = [m for m, _ in method_calls]
|
|
251
|
+
self.assertEqual(methods, [
|
|
252
|
+
'textDocument/references',
|
|
253
|
+
'textDocument/definition',
|
|
254
|
+
'textDocument/hover',
|
|
255
|
+
])
|
|
256
|
+
# find_references must pass `context.includeDeclaration`.
|
|
257
|
+
ref_params = method_calls[0][1]
|
|
258
|
+
self.assertIn('context', ref_params)
|
|
259
|
+
self.assertEqual(ref_params['context']['includeDeclaration'], True)
|
|
260
|
+
# All carry a 0-indexed position.
|
|
261
|
+
for _m, p in method_calls:
|
|
262
|
+
self.assertEqual(p['position'], {'line': 5, 'character': 3})
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
class CleanupTests(unittest.TestCase):
|
|
266
|
+
def setUp(self):
|
|
267
|
+
self.lp = _import_lsp_proxy()
|
|
268
|
+
self.lp._reset_detection_cache()
|
|
269
|
+
with self.lp._clients_lock:
|
|
270
|
+
self.lp._clients.clear()
|
|
271
|
+
# Run inside a tempdir so the PID file write goes there, not into
|
|
272
|
+
# the live project's .loki/.
|
|
273
|
+
self._tmpdir = tempfile.mkdtemp(prefix='lsp-proxy-test-')
|
|
274
|
+
self._prev_cwd = os.getcwd()
|
|
275
|
+
os.chdir(self._tmpdir)
|
|
276
|
+
|
|
277
|
+
def tearDown(self):
|
|
278
|
+
os.chdir(self._prev_cwd)
|
|
279
|
+
with self.lp._clients_lock:
|
|
280
|
+
self.lp._clients.clear()
|
|
281
|
+
import shutil as _sh
|
|
282
|
+
_sh.rmtree(self._tmpdir, ignore_errors=True)
|
|
283
|
+
|
|
284
|
+
def test_cleanup_calls_shutdown_on_every_client(self):
|
|
285
|
+
fake_a = mock.MagicMock()
|
|
286
|
+
fake_b = mock.MagicMock()
|
|
287
|
+
fake_a.language = 'python'
|
|
288
|
+
fake_b.language = 'go'
|
|
289
|
+
with self.lp._clients_lock:
|
|
290
|
+
self.lp._clients['python'] = fake_a
|
|
291
|
+
self.lp._clients['go'] = fake_b
|
|
292
|
+
self.lp._cleanup_all_clients()
|
|
293
|
+
fake_a.shutdown.assert_called_once()
|
|
294
|
+
fake_b.shutdown.assert_called_once()
|
|
295
|
+
# Registry is cleared after cleanup so subsequent calls are no-ops.
|
|
296
|
+
with self.lp._clients_lock:
|
|
297
|
+
self.assertEqual(self.lp._clients, {})
|
|
298
|
+
|
|
299
|
+
def test_cleanup_clears_pid_file(self):
|
|
300
|
+
pids_dir = Path(self._tmpdir) / '.loki' / 'lsp'
|
|
301
|
+
pids_dir.mkdir(parents=True)
|
|
302
|
+
pid_path = pids_dir / 'pids.json'
|
|
303
|
+
pid_path.write_text(json.dumps({'python': 12345}))
|
|
304
|
+
self.assertTrue(pid_path.exists())
|
|
305
|
+
self.lp._cleanup_all_clients()
|
|
306
|
+
self.assertFalse(pid_path.exists())
|
|
307
|
+
|
|
308
|
+
def test_record_pid_to_disk_creates_parent_dir(self):
|
|
309
|
+
# First call must mkdir -p the .loki/lsp/ dir.
|
|
310
|
+
self.lp._record_pid_to_disk('python', 99999)
|
|
311
|
+
pid_path = Path(self._tmpdir) / '.loki' / 'lsp' / 'pids.json'
|
|
312
|
+
self.assertTrue(pid_path.exists())
|
|
313
|
+
data = json.loads(pid_path.read_text())
|
|
314
|
+
self.assertEqual(data['python'], 99999)
|
|
315
|
+
# Second call must merge, not overwrite.
|
|
316
|
+
self.lp._record_pid_to_disk('go', 88888)
|
|
317
|
+
data = json.loads(pid_path.read_text())
|
|
318
|
+
self.assertEqual(data['python'], 99999)
|
|
319
|
+
self.assertEqual(data['go'], 88888)
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
class LSPClientShutdownTests(unittest.TestCase):
|
|
323
|
+
"""Test LSPClient.shutdown() sends shutdown + exit JSON-RPC messages
|
|
324
|
+
and terminates the subprocess. We mock subprocess.Popen so no real
|
|
325
|
+
LSP binary is needed."""
|
|
326
|
+
|
|
327
|
+
def setUp(self):
|
|
328
|
+
self.lp = _import_lsp_proxy()
|
|
329
|
+
|
|
330
|
+
def test_shutdown_sends_shutdown_exit_and_terminates(self):
|
|
331
|
+
fake_proc = mock.MagicMock()
|
|
332
|
+
# poll() must return None at the start (alive), then a small int
|
|
333
|
+
# after terminate() (so the grace loop exits and we don't have
|
|
334
|
+
# to actually sleep 2s in the test).
|
|
335
|
+
fake_proc.poll.side_effect = [None, None, 0, 0, 0, 0]
|
|
336
|
+
fake_proc.stdin = io.BytesIO()
|
|
337
|
+
client = self.lp.LSPClient('python', '/usr/bin/pylsp', [])
|
|
338
|
+
client.proc = fake_proc
|
|
339
|
+
client._initialized = True
|
|
340
|
+
client.shutdown()
|
|
341
|
+
wire = fake_proc.stdin.getvalue()
|
|
342
|
+
# Two framed messages should have been written.
|
|
343
|
+
self.assertEqual(wire.count(b'Content-Length:'), 2)
|
|
344
|
+
self.assertIn(b'"shutdown"', wire)
|
|
345
|
+
self.assertIn(b'"exit"', wire)
|
|
346
|
+
# No need to assert terminate() count exactly -- if poll() never
|
|
347
|
+
# returns None inside the grace loop, terminate() may not be
|
|
348
|
+
# called. We only require the messages went out cleanly.
|
|
349
|
+
|
|
350
|
+
def test_shutdown_sigterms_when_process_does_not_exit(self):
|
|
351
|
+
fake_proc = mock.MagicMock()
|
|
352
|
+
# poll() always returns None (process never voluntarily exits).
|
|
353
|
+
# We use a callable so we don't run out of side_effect values.
|
|
354
|
+
fake_proc.poll = mock.MagicMock(return_value=None)
|
|
355
|
+
fake_proc.stdin = io.BytesIO()
|
|
356
|
+
client = self.lp.LSPClient('python', '/usr/bin/pylsp', [])
|
|
357
|
+
client.proc = fake_proc
|
|
358
|
+
client._initialized = True
|
|
359
|
+
# Stub time.sleep AND time.time so the grace loops exit
|
|
360
|
+
# immediately rather than spinning for 3s of wall time.
|
|
361
|
+
times = iter([0.0, 100.0, 200.0, 300.0])
|
|
362
|
+
|
|
363
|
+
def fake_time():
|
|
364
|
+
try:
|
|
365
|
+
return next(times)
|
|
366
|
+
except StopIteration:
|
|
367
|
+
return 1000.0
|
|
368
|
+
|
|
369
|
+
with mock.patch.object(self.lp.time, 'sleep', return_value=None), \
|
|
370
|
+
mock.patch.object(self.lp.time, 'time', side_effect=fake_time):
|
|
371
|
+
client.shutdown()
|
|
372
|
+
fake_proc.terminate.assert_called()
|
|
373
|
+
fake_proc.kill.assert_called()
|
|
374
|
+
|
|
375
|
+
|
|
376
|
+
if __name__ == '__main__':
|
|
377
|
+
unittest.main()
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
"""App graph -- shared memory + CLAUDE.md across sibling project members.
|
|
2
|
+
|
|
3
|
+
Phase F cross-project context. Reads `.loki/app.json` manifests written by
|
|
4
|
+
the bash route (or the Bun route in `loki-ts/src/project_graph.ts`) and
|
|
5
|
+
exposes the resulting graph to Python consumers. The graph is informational:
|
|
6
|
+
the actual shared-memory plumbing happens via the `LOKI_MEMORY_BASE_PATH`
|
|
7
|
+
env override which `MemoryStorage` honors transparently.
|
|
8
|
+
|
|
9
|
+
Scope: only CLAUDE.md + memory are shared. State, queue, and checkpoints
|
|
10
|
+
stay per-member. No symlinks are created. Wraps `CrossProjectIndex` so
|
|
11
|
+
existing cross-project memory lookups remain available within an app
|
|
12
|
+
graph.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import json
|
|
18
|
+
import os
|
|
19
|
+
import re
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
from typing import List, Optional
|
|
22
|
+
|
|
23
|
+
from .cross_project import CrossProjectIndex
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
APP_ID_REGEX = re.compile(r"^[a-z0-9-]{3,40}$")
|
|
27
|
+
SUPPORTED_SCHEMA_VERSION = 1
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class AppGraph:
|
|
31
|
+
"""An application graph composed of sibling project members.
|
|
32
|
+
|
|
33
|
+
Constructed either from env vars (set by the bash/Bun discovery routine)
|
|
34
|
+
or directly from a `.loki/app.json` manifest. Holds the canonical view
|
|
35
|
+
of which directories share memory + CLAUDE.md.
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
def __init__(
|
|
39
|
+
self,
|
|
40
|
+
app_id: str,
|
|
41
|
+
root: Path,
|
|
42
|
+
members: List[Path],
|
|
43
|
+
shared_memory_dir: Optional[Path] = None,
|
|
44
|
+
) -> None:
|
|
45
|
+
if not APP_ID_REGEX.match(app_id):
|
|
46
|
+
raise ValueError(
|
|
47
|
+
f"invalid app_id '{app_id}': must match {APP_ID_REGEX.pattern}"
|
|
48
|
+
)
|
|
49
|
+
self.app_id = app_id
|
|
50
|
+
self.root = Path(root)
|
|
51
|
+
self._members = [Path(m) for m in members]
|
|
52
|
+
# Note: shared_memory_dir is a 4th param beyond the documented spec
|
|
53
|
+
# (architect deviation -- see report). The spec lets app.json declare
|
|
54
|
+
# `shared_memory_dir`, but the documented constructor signature has
|
|
55
|
+
# no slot for it. Without this field, manifests that set the field
|
|
56
|
+
# would silently lose it on round-trip through from_app_json().
|
|
57
|
+
self._shared_memory_dir = (
|
|
58
|
+
Path(shared_memory_dir) if shared_memory_dir is not None else None
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
def get_shared_memory_path(self) -> Path:
|
|
62
|
+
"""Return the shared memory directory for this app graph.
|
|
63
|
+
|
|
64
|
+
Defaults to `<root>/.loki-shared/memory/`. If the manifest declared
|
|
65
|
+
a `shared_memory_dir`, that value is honored verbatim (treated as
|
|
66
|
+
an absolute path or resolved against the graph root if relative).
|
|
67
|
+
"""
|
|
68
|
+
if self._shared_memory_dir is not None:
|
|
69
|
+
p = self._shared_memory_dir
|
|
70
|
+
if not p.is_absolute():
|
|
71
|
+
p = self.root / p
|
|
72
|
+
return p
|
|
73
|
+
return self.root / ".loki-shared" / "memory"
|
|
74
|
+
|
|
75
|
+
def get_members(self) -> List[Path]:
|
|
76
|
+
"""Return absolute member directory paths."""
|
|
77
|
+
return [m.resolve() if not m.is_absolute() else m for m in self._members]
|
|
78
|
+
|
|
79
|
+
def cross_project_index(self) -> CrossProjectIndex:
|
|
80
|
+
"""Return a CrossProjectIndex scoped to this app's member dirs.
|
|
81
|
+
|
|
82
|
+
Lets callers reuse the existing cross-project discovery code over
|
|
83
|
+
just the members of the current app graph instead of the global
|
|
84
|
+
home/git/projects search dirs.
|
|
85
|
+
"""
|
|
86
|
+
return CrossProjectIndex(search_dirs=[m.parent for m in self.get_members()])
|
|
87
|
+
|
|
88
|
+
@classmethod
|
|
89
|
+
def from_env(cls) -> Optional["AppGraph"]:
|
|
90
|
+
"""Build from LOKI_PROJECT_GRAPH_* env vars set by discovery.
|
|
91
|
+
|
|
92
|
+
Returns None when LOKI_PROJECT_GRAPH_ROOT is not in the environment.
|
|
93
|
+
"""
|
|
94
|
+
root = os.environ.get("LOKI_PROJECT_GRAPH_ROOT")
|
|
95
|
+
app_id = os.environ.get("LOKI_PROJECT_GRAPH_APP_ID")
|
|
96
|
+
members_raw = os.environ.get("LOKI_PROJECT_GRAPH_MEMBERS", "")
|
|
97
|
+
shared_mem_raw = os.environ.get("LOKI_PROJECT_GRAPH_SHARED_MEMORY_DIR")
|
|
98
|
+
if not root or not app_id:
|
|
99
|
+
return None
|
|
100
|
+
members = [Path(p) for p in members_raw.split(":") if p]
|
|
101
|
+
shared_memory_dir = Path(shared_mem_raw) if shared_mem_raw else None
|
|
102
|
+
return cls(
|
|
103
|
+
app_id=app_id,
|
|
104
|
+
root=Path(root),
|
|
105
|
+
members=members,
|
|
106
|
+
shared_memory_dir=shared_memory_dir,
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
@classmethod
|
|
110
|
+
def from_app_json(cls, path: Path) -> "AppGraph":
|
|
111
|
+
"""Parse a `.loki/app.json` manifest. Raises on schema mismatch.
|
|
112
|
+
|
|
113
|
+
`path` may point at the manifest file itself or at a directory that
|
|
114
|
+
contains `.loki/app.json`. The graph root is the directory that
|
|
115
|
+
contains `.loki/`.
|
|
116
|
+
"""
|
|
117
|
+
path = Path(path)
|
|
118
|
+
if path.is_dir():
|
|
119
|
+
manifest_path = path / ".loki" / "app.json"
|
|
120
|
+
else:
|
|
121
|
+
manifest_path = path
|
|
122
|
+
if not manifest_path.is_file():
|
|
123
|
+
raise FileNotFoundError(f"app.json not found at {manifest_path}")
|
|
124
|
+
with open(manifest_path, "r", encoding="utf-8") as f:
|
|
125
|
+
data = json.load(f)
|
|
126
|
+
schema_version = data.get("schema_version")
|
|
127
|
+
if schema_version != SUPPORTED_SCHEMA_VERSION:
|
|
128
|
+
raise ValueError(
|
|
129
|
+
f"unsupported schema_version {schema_version!r} in {manifest_path}; "
|
|
130
|
+
f"expected {SUPPORTED_SCHEMA_VERSION}"
|
|
131
|
+
)
|
|
132
|
+
app_id = data.get("app_id", "")
|
|
133
|
+
if not isinstance(app_id, str) or not APP_ID_REGEX.match(app_id):
|
|
134
|
+
raise ValueError(f"invalid app_id {app_id!r} in {manifest_path}")
|
|
135
|
+
# Root is the parent of .loki/
|
|
136
|
+
root = manifest_path.parent.parent
|
|
137
|
+
declared = data.get("members", []) or []
|
|
138
|
+
members: List[Path] = []
|
|
139
|
+
if isinstance(declared, list):
|
|
140
|
+
for name in declared:
|
|
141
|
+
if not isinstance(name, str):
|
|
142
|
+
continue
|
|
143
|
+
candidate = root / name
|
|
144
|
+
if candidate.is_dir():
|
|
145
|
+
members.append(candidate.resolve())
|
|
146
|
+
shared_raw = data.get("shared_memory_dir")
|
|
147
|
+
shared_memory_dir = Path(shared_raw) if isinstance(shared_raw, str) else None
|
|
148
|
+
return cls(
|
|
149
|
+
app_id=app_id,
|
|
150
|
+
root=root.resolve(),
|
|
151
|
+
members=members,
|
|
152
|
+
shared_memory_dir=shared_memory_dir,
|
|
153
|
+
)
|
package/memory/storage.py
CHANGED
|
@@ -64,7 +64,12 @@ class MemoryStorage:
|
|
|
64
64
|
If provided, memories are stored in base_path/{namespace}/
|
|
65
65
|
Defaults to None (uses base_path directly for backward compat).
|
|
66
66
|
"""
|
|
67
|
-
|
|
67
|
+
# LOKI_MEMORY_BASE_PATH env override (Phase F cross-project context).
|
|
68
|
+
# When set, all MemoryStorage instances under the same app graph
|
|
69
|
+
# write to the shared memory dir. Backward compatible: when unset,
|
|
70
|
+
# the caller-provided base_path is used (original behavior).
|
|
71
|
+
effective_base = os.environ.get("LOKI_MEMORY_BASE_PATH", base_path)
|
|
72
|
+
self._root_path = Path(effective_base)
|
|
68
73
|
self._namespace = namespace
|
|
69
74
|
|
|
70
75
|
# Validate namespace to prevent path traversal
|