cli-mem 0.3.5__tar.gz → 0.3.6__tar.gz
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.
Potentially problematic release.
This version of cli-mem might be problematic. Click here for more details.
- {cli_mem-0.3.5 → cli_mem-0.3.6}/PKG-INFO +1 -1
- {cli_mem-0.3.5 → cli_mem-0.3.6}/pyproject.toml +1 -1
- {cli_mem-0.3.5 → cli_mem-0.3.6}/src/mem/__init__.py +1 -1
- {cli_mem-0.3.5 → cli_mem-0.3.6}/src/mem/capture.py +26 -0
- {cli_mem-0.3.5 → cli_mem-0.3.6}/src/mem/cli.py +13 -21
- {cli_mem-0.3.5 → cli_mem-0.3.6}/src/mem/models.py +1 -0
- {cli_mem-0.3.5 → cli_mem-0.3.6}/src/mem/patterns.py +65 -8
- {cli_mem-0.3.5 → cli_mem-0.3.6}/src/mem/storage.py +30 -0
- {cli_mem-0.3.5 → cli_mem-0.3.6}/tests/conftest.py +4 -0
- {cli_mem-0.3.5 → cli_mem-0.3.6}/tests/test_patterns.py +107 -1
- {cli_mem-0.3.5 → cli_mem-0.3.6}/.github/workflows/ci.yml +0 -0
- {cli_mem-0.3.5 → cli_mem-0.3.6}/.github/workflows/release.yml +0 -0
- {cli_mem-0.3.5 → cli_mem-0.3.6}/.gitignore +0 -0
- {cli_mem-0.3.5 → cli_mem-0.3.6}/ARCHITECTURE.md +0 -0
- {cli_mem-0.3.5 → cli_mem-0.3.6}/LICENSE +0 -0
- {cli_mem-0.3.5 → cli_mem-0.3.6}/PHILOSOPHY.md +0 -0
- {cli_mem-0.3.5 → cli_mem-0.3.6}/README.md +0 -0
- {cli_mem-0.3.5 → cli_mem-0.3.6}/assets/.gitkeep +0 -0
- {cli_mem-0.3.5 → cli_mem-0.3.6}/docs/decisions/001-jsonl-over-sqlite.md +0 -0
- {cli_mem-0.3.5 → cli_mem-0.3.6}/docs/decisions/002-apple-fm-sdk-for-patterns.md +0 -0
- {cli_mem-0.3.5 → cli_mem-0.3.6}/docs/decisions/003-no-daemon.md +0 -0
- {cli_mem-0.3.5 → cli_mem-0.3.6}/docs/decisions/004-per-repo-jsonl.md +0 -0
- {cli_mem-0.3.5 → cli_mem-0.3.6}/hooks/mem.zsh +0 -0
- {cli_mem-0.3.5 → cli_mem-0.3.6}/install.sh +0 -0
- {cli_mem-0.3.5 → cli_mem-0.3.6}/src/mem/_generable.py +0 -0
- {cli_mem-0.3.5 → cli_mem-0.3.6}/src/mem/groups.py +0 -0
- {cli_mem-0.3.5 → cli_mem-0.3.6}/src/mem/search.py +0 -0
- {cli_mem-0.3.5 → cli_mem-0.3.6}/src/mem/variables.py +0 -0
- {cli_mem-0.3.5 → cli_mem-0.3.6}/tests/test_capture.py +0 -0
- {cli_mem-0.3.5 → cli_mem-0.3.6}/tests/test_groups.py +0 -0
- {cli_mem-0.3.5 → cli_mem-0.3.6}/tests/test_search.py +0 -0
- {cli_mem-0.3.5 → cli_mem-0.3.6}/tests/test_storage.py +0 -0
|
@@ -70,6 +70,32 @@ def capture_command(raw: str, directory: str, exit_code: int, duration_ms: int)
|
|
|
70
70
|
except Exception:
|
|
71
71
|
pass # Session tracking failure should never block capture
|
|
72
72
|
|
|
73
|
+
# Auto-sync: trigger background pattern extraction every N captures
|
|
74
|
+
try:
|
|
75
|
+
count = storage.increment_sync_counter()
|
|
76
|
+
if count >= storage.SYNC_THRESHOLD:
|
|
77
|
+
storage.reset_sync_counter()
|
|
78
|
+
_spawn_background_sync()
|
|
79
|
+
except Exception:
|
|
80
|
+
pass # Auto-sync failure should never block capture
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _spawn_background_sync() -> None:
|
|
84
|
+
"""Spawn `mem _sync` as a fully detached background process.
|
|
85
|
+
|
|
86
|
+
The subprocess inherits nothing from the parent — no stdout, no stderr,
|
|
87
|
+
no wait. It runs and exits silently.
|
|
88
|
+
"""
|
|
89
|
+
import sys as _sys
|
|
90
|
+
|
|
91
|
+
mem_exe = _sys.executable
|
|
92
|
+
subprocess.Popen(
|
|
93
|
+
[mem_exe, "-m", "mem.cli", "_sync"],
|
|
94
|
+
stdout=subprocess.DEVNULL,
|
|
95
|
+
stderr=subprocess.DEVNULL,
|
|
96
|
+
start_new_session=True,
|
|
97
|
+
)
|
|
98
|
+
|
|
73
99
|
|
|
74
100
|
class SessionTracker:
|
|
75
101
|
"""Tracks work sessions across shell commands.
|
|
@@ -209,29 +209,21 @@ add-zsh-hook precmd _mem_precmd
|
|
|
209
209
|
"""
|
|
210
210
|
|
|
211
211
|
|
|
212
|
-
@cli.command()
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
def sync(keep_commands: int, keep_sessions: int) -> None:
|
|
216
|
-
"""Extract patterns and rotate old data."""
|
|
217
|
-
from rich.progress import Progress
|
|
218
|
-
|
|
219
|
-
from mem.patterns import sync_all_patterns
|
|
220
|
-
from mem import storage
|
|
221
|
-
|
|
222
|
-
# Pattern extraction
|
|
223
|
-
with Progress(console=console) as progress:
|
|
224
|
-
task = progress.add_task("Extracting patterns...", total=None)
|
|
225
|
-
new, updated = sync_all_patterns()
|
|
226
|
-
progress.update(task, completed=100, total=100)
|
|
212
|
+
@cli.command(name="_sync", hidden=True)
|
|
213
|
+
def sync_cmd() -> None:
|
|
214
|
+
"""Internal: background pattern extraction and data rotation.
|
|
227
215
|
|
|
228
|
-
|
|
216
|
+
Triggered automatically every 20 captured commands. Runs silently —
|
|
217
|
+
no output, no errors. Never called by the user directly.
|
|
218
|
+
"""
|
|
219
|
+
try:
|
|
220
|
+
from mem.patterns import sync_all_patterns
|
|
221
|
+
from mem import storage
|
|
229
222
|
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
console.print(f" Sessions older than {keep_sessions}d: {sess_removed} removed")
|
|
223
|
+
sync_all_patterns(silent=True)
|
|
224
|
+
storage.rotate()
|
|
225
|
+
except Exception:
|
|
226
|
+
pass # Background task — never surface errors
|
|
235
227
|
|
|
236
228
|
|
|
237
229
|
@cli.command()
|
|
@@ -118,24 +118,56 @@ async def _generalize_commands(tool: str, unique_commands: list[str]) -> dict[st
|
|
|
118
118
|
|
|
119
119
|
|
|
120
120
|
async def extract_patterns_for_tool(
|
|
121
|
-
tool: str,
|
|
121
|
+
tool: str,
|
|
122
|
+
commands: list[str],
|
|
123
|
+
already_processed: set[str] | None = None,
|
|
122
124
|
) -> PatternExtractionResult:
|
|
123
125
|
"""Extract abstract patterns from a list of concrete commands.
|
|
124
126
|
|
|
125
127
|
Strategy:
|
|
126
128
|
1. Deduplicate commands and count frequencies (code)
|
|
127
129
|
2. Generalize each unique command via LLM (if available)
|
|
130
|
+
— skips commands in already_processed (cache hit)
|
|
128
131
|
3. Aggregate frequencies by generalized pattern (code)
|
|
129
132
|
|
|
130
133
|
Falls back to simple frequency grouping if SDK is unavailable.
|
|
131
134
|
"""
|
|
135
|
+
if already_processed is None:
|
|
136
|
+
already_processed = set()
|
|
137
|
+
|
|
132
138
|
# Step 1: Count raw frequencies (code — fast and exact)
|
|
133
139
|
raw_freq = Counter(commands)
|
|
134
140
|
unique_cmds = list(raw_freq.keys())
|
|
135
141
|
|
|
136
142
|
if _apple_fm_available():
|
|
137
|
-
# Step 2: Generalize unique commands (LLM
|
|
138
|
-
|
|
143
|
+
# Step 2: Generalize only NEW unique commands (LLM)
|
|
144
|
+
new_cmds = [c for c in unique_cmds if c not in already_processed]
|
|
145
|
+
|
|
146
|
+
# Load existing patterns to reuse cached generalizations
|
|
147
|
+
existing_pf = storage.read_patterns(tool)
|
|
148
|
+
cached_map: dict[str, str] = {}
|
|
149
|
+
if existing_pf:
|
|
150
|
+
# Rebuild command->pattern map from existing data
|
|
151
|
+
for p in existing_pf.patterns:
|
|
152
|
+
cached_map[p.example] = p.pattern
|
|
153
|
+
|
|
154
|
+
if new_cmds:
|
|
155
|
+
new_map = await _generalize_commands(tool, new_cmds)
|
|
156
|
+
else:
|
|
157
|
+
new_map = {}
|
|
158
|
+
|
|
159
|
+
# Merge: cached + new
|
|
160
|
+
cmd_to_pattern: dict[str, str] = {}
|
|
161
|
+
for cmd in unique_cmds:
|
|
162
|
+
if cmd in new_map:
|
|
163
|
+
cmd_to_pattern[cmd] = new_map[cmd]
|
|
164
|
+
elif cmd in cached_map:
|
|
165
|
+
cmd_to_pattern[cmd] = cached_map[cmd]
|
|
166
|
+
else:
|
|
167
|
+
# Command was processed before but not in cache
|
|
168
|
+
# (e.g., its example was a different command for same pattern)
|
|
169
|
+
# Fall back to raw command
|
|
170
|
+
cmd_to_pattern[cmd] = cmd
|
|
139
171
|
|
|
140
172
|
# Step 3: Aggregate by pattern (code — exact counting)
|
|
141
173
|
pattern_freq: Counter[str] = Counter()
|
|
@@ -197,34 +229,59 @@ def run_pattern_extraction(tool: str) -> None:
|
|
|
197
229
|
|
|
198
230
|
Reads all commands starting with the tool name from storage,
|
|
199
231
|
runs extraction, and writes the result to patterns/<tool>.json.
|
|
232
|
+
|
|
233
|
+
Uses caching: only sends new (unprocessed) commands to the LLM.
|
|
234
|
+
Already-processed commands are tracked in the PatternFile.
|
|
200
235
|
"""
|
|
201
236
|
import asyncio
|
|
202
237
|
|
|
203
|
-
|
|
238
|
+
all_commands = [
|
|
204
239
|
cmd.command
|
|
205
240
|
for cmd in storage.read_all_commands()
|
|
206
241
|
if cmd.command.split()[0] == tool
|
|
207
242
|
]
|
|
208
243
|
|
|
209
|
-
if len(
|
|
244
|
+
if len(all_commands) < 5:
|
|
210
245
|
return # Not enough data for meaningful patterns
|
|
211
246
|
|
|
212
|
-
|
|
247
|
+
# Load existing pattern file for cache
|
|
248
|
+
existing = storage.read_patterns(tool)
|
|
249
|
+
already_processed: set[str] = set()
|
|
250
|
+
if existing and existing.processed_commands:
|
|
251
|
+
already_processed = set(existing.processed_commands)
|
|
252
|
+
|
|
253
|
+
# Only send new commands to the LLM
|
|
254
|
+
new_commands = [c for c in all_commands if c not in already_processed]
|
|
255
|
+
|
|
256
|
+
if not new_commands and existing:
|
|
257
|
+
return # Nothing new to process
|
|
258
|
+
|
|
259
|
+
# Extract patterns from ALL commands (new + old) but only generalize new ones
|
|
260
|
+
result = asyncio.run(
|
|
261
|
+
extract_patterns_for_tool(tool, all_commands, already_processed)
|
|
262
|
+
)
|
|
263
|
+
|
|
264
|
+
# Track all unique commands as processed
|
|
265
|
+
all_unique = list(set(all_commands))
|
|
213
266
|
|
|
214
267
|
pf = PatternFile(
|
|
215
268
|
tool=tool,
|
|
216
269
|
patterns=result.patterns,
|
|
217
270
|
last_updated=int(time.time()),
|
|
271
|
+
processed_commands=all_unique,
|
|
218
272
|
)
|
|
219
273
|
storage.write_patterns(pf)
|
|
220
274
|
|
|
221
275
|
|
|
222
|
-
def sync_all_patterns() -> tuple[int, int]:
|
|
276
|
+
def sync_all_patterns(silent: bool = False) -> tuple[int, int]:
|
|
223
277
|
"""Extract patterns for ALL tools with sufficient command history.
|
|
224
278
|
|
|
225
279
|
Detects unique tools (first token of each command), runs extraction
|
|
226
280
|
for each tool with >5 commands. Skips tools with insufficient data.
|
|
227
281
|
|
|
282
|
+
Args:
|
|
283
|
+
silent: If True, suppress all output (for background auto-sync).
|
|
284
|
+
|
|
228
285
|
Returns (new_patterns, updated_patterns) counts.
|
|
229
286
|
"""
|
|
230
287
|
# Collect all commands grouped by tool (first token)
|
|
@@ -249,7 +306,7 @@ def sync_all_patterns() -> tuple[int, int]:
|
|
|
249
306
|
else:
|
|
250
307
|
updated_count += 1
|
|
251
308
|
|
|
252
|
-
if not _apple_fm_available():
|
|
309
|
+
if not silent and not _apple_fm_available():
|
|
253
310
|
print(
|
|
254
311
|
"Tip: install AI support for smarter pattern extraction: "
|
|
255
312
|
"pip install cli-mem[ai]",
|
|
@@ -312,6 +312,36 @@ def forget_commands(query: str) -> int:
|
|
|
312
312
|
return removed
|
|
313
313
|
|
|
314
314
|
|
|
315
|
+
# --- Sync counter ---
|
|
316
|
+
|
|
317
|
+
SYNC_COUNTER_FILE = MEM_DIR / ".sync_counter"
|
|
318
|
+
SYNC_THRESHOLD = 20
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
def read_sync_counter() -> int:
|
|
322
|
+
"""Read the number of captures since last sync."""
|
|
323
|
+
if not SYNC_COUNTER_FILE.exists():
|
|
324
|
+
return 0
|
|
325
|
+
try:
|
|
326
|
+
return int(SYNC_COUNTER_FILE.read_text(encoding="utf-8").strip())
|
|
327
|
+
except (ValueError, OSError):
|
|
328
|
+
return 0
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
def increment_sync_counter() -> int:
|
|
332
|
+
"""Increment capture counter and return new value."""
|
|
333
|
+
count = read_sync_counter() + 1
|
|
334
|
+
ensure_dirs()
|
|
335
|
+
SYNC_COUNTER_FILE.write_text(str(count), encoding="utf-8")
|
|
336
|
+
return count
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
def reset_sync_counter() -> None:
|
|
340
|
+
"""Reset capture counter after a sync."""
|
|
341
|
+
ensure_dirs()
|
|
342
|
+
SYNC_COUNTER_FILE.write_text("0", encoding="utf-8")
|
|
343
|
+
|
|
344
|
+
|
|
315
345
|
# --- Named Groups storage ---
|
|
316
346
|
|
|
317
347
|
|
|
@@ -21,6 +21,10 @@ def tmp_mem_dir(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path:
|
|
|
21
21
|
monkeypatch.setattr(
|
|
22
22
|
storage, "GROUPS_GLOBAL_FILE", tmp_path / "groups" / "_global.json"
|
|
23
23
|
)
|
|
24
|
+
monkeypatch.setattr(
|
|
25
|
+
storage, "SYNC_COUNTER_FILE", tmp_path / ".sync_counter"
|
|
26
|
+
)
|
|
27
|
+
monkeypatch.setattr(storage, "VARS_FILE", tmp_path / "vars.json")
|
|
24
28
|
return tmp_path
|
|
25
29
|
|
|
26
30
|
|
|
@@ -463,7 +463,7 @@ class TestDeduplication:
|
|
|
463
463
|
|
|
464
464
|
class TestSyncAllPatterns:
|
|
465
465
|
def test_sync_warns_without_sdk(self, tmp_mem_dir, capsys):
|
|
466
|
-
"""sync_all_patterns prints warning when SDK is unavailable."""
|
|
466
|
+
"""sync_all_patterns prints warning when SDK is unavailable (non-silent)."""
|
|
467
467
|
now = int(time.time())
|
|
468
468
|
for i in range(6):
|
|
469
469
|
storage.append_command(
|
|
@@ -481,6 +481,25 @@ class TestSyncAllPatterns:
|
|
|
481
481
|
captured = capsys.readouterr()
|
|
482
482
|
assert "pip install cli-mem[ai]" in captured.err
|
|
483
483
|
|
|
484
|
+
def test_sync_silent_no_output(self, tmp_mem_dir, capsys):
|
|
485
|
+
"""sync_all_patterns(silent=True) produces no output."""
|
|
486
|
+
now = int(time.time())
|
|
487
|
+
for i in range(6):
|
|
488
|
+
storage.append_command(
|
|
489
|
+
make_command(
|
|
490
|
+
command=f"make target-{i}",
|
|
491
|
+
ts=now,
|
|
492
|
+
repo="/Users/test/projects/myapp",
|
|
493
|
+
)
|
|
494
|
+
)
|
|
495
|
+
|
|
496
|
+
with patch.object(patterns, "_apple_fm_available", return_value=False):
|
|
497
|
+
new, updated = patterns.sync_all_patterns(silent=True)
|
|
498
|
+
|
|
499
|
+
assert new == 1
|
|
500
|
+
captured = capsys.readouterr()
|
|
501
|
+
assert captured.err == ""
|
|
502
|
+
|
|
484
503
|
def test_sync_skips_tools_below_threshold(self, tmp_mem_dir):
|
|
485
504
|
"""Tools with <5 commands are skipped entirely."""
|
|
486
505
|
now = int(time.time())
|
|
@@ -524,6 +543,93 @@ class TestSyncAllPatterns:
|
|
|
524
543
|
assert storage.read_patterns("tool-c") is None
|
|
525
544
|
|
|
526
545
|
|
|
546
|
+
class TestPatternCaching:
|
|
547
|
+
"""Verify that already-processed commands skip the LLM."""
|
|
548
|
+
|
|
549
|
+
@pytest.mark.asyncio
|
|
550
|
+
async def test_cached_commands_skip_llm(self):
|
|
551
|
+
"""Commands in already_processed set should not trigger LLM calls."""
|
|
552
|
+
call_count = 0
|
|
553
|
+
|
|
554
|
+
async def _counting_respond(prompt: str, generating=None):
|
|
555
|
+
nonlocal call_count
|
|
556
|
+
call_count += 1
|
|
557
|
+
for line in prompt.splitlines():
|
|
558
|
+
if line.startswith("Command:"):
|
|
559
|
+
cmd = line.split("Command:", 1)[1].strip()
|
|
560
|
+
return _make_mock_generalized(f"{cmd} <generalized>")
|
|
561
|
+
return _make_mock_generalized("unknown")
|
|
562
|
+
|
|
563
|
+
mock_session = MockSession(respond_fn=_counting_respond)
|
|
564
|
+
|
|
565
|
+
with (
|
|
566
|
+
patch.object(patterns, "_apple_fm_available", return_value=True),
|
|
567
|
+
patch("mem.patterns._get_generable_types", return_value=MagicMock()),
|
|
568
|
+
patch("apple_fm_sdk.LanguageModelSession", return_value=mock_session),
|
|
569
|
+
):
|
|
570
|
+
# First call: 3 unique commands, all new
|
|
571
|
+
commands = ["git status", "git log", "git diff", "git status", "git log"]
|
|
572
|
+
await patterns.extract_patterns_for_tool("git", commands)
|
|
573
|
+
|
|
574
|
+
assert call_count == 3 # 3 unique commands
|
|
575
|
+
|
|
576
|
+
# Second call with cache: only 1 new command
|
|
577
|
+
call_count = 0
|
|
578
|
+
already_done = {"git status", "git log", "git diff"}
|
|
579
|
+
|
|
580
|
+
with (
|
|
581
|
+
patch.object(patterns, "_apple_fm_available", return_value=True),
|
|
582
|
+
patch("mem.patterns._get_generable_types", return_value=MagicMock()),
|
|
583
|
+
patch("apple_fm_sdk.LanguageModelSession", return_value=mock_session),
|
|
584
|
+
):
|
|
585
|
+
commands2 = commands + ["git push"]
|
|
586
|
+
await patterns.extract_patterns_for_tool(
|
|
587
|
+
"git", commands2, already_done
|
|
588
|
+
)
|
|
589
|
+
|
|
590
|
+
assert call_count == 1 # Only "git push" is new
|
|
591
|
+
|
|
592
|
+
|
|
593
|
+
class TestAutoSync:
|
|
594
|
+
"""Verify the sync counter and auto-trigger logic."""
|
|
595
|
+
|
|
596
|
+
def test_counter_increment(self, tmp_mem_dir):
|
|
597
|
+
assert storage.read_sync_counter() == 0
|
|
598
|
+
assert storage.increment_sync_counter() == 1
|
|
599
|
+
assert storage.increment_sync_counter() == 2
|
|
600
|
+
assert storage.read_sync_counter() == 2
|
|
601
|
+
|
|
602
|
+
def test_counter_reset(self, tmp_mem_dir):
|
|
603
|
+
storage.increment_sync_counter()
|
|
604
|
+
storage.increment_sync_counter()
|
|
605
|
+
storage.reset_sync_counter()
|
|
606
|
+
assert storage.read_sync_counter() == 0
|
|
607
|
+
|
|
608
|
+
def test_capture_triggers_sync_at_threshold(self, tmp_mem_dir):
|
|
609
|
+
"""After SYNC_THRESHOLD captures, _spawn_background_sync is called."""
|
|
610
|
+
from mem import capture
|
|
611
|
+
|
|
612
|
+
with (
|
|
613
|
+
patch.object(storage, "SYNC_THRESHOLD", 3),
|
|
614
|
+
patch.object(capture, "_spawn_background_sync") as mock_spawn,
|
|
615
|
+
patch.object(capture, "get_git_repo", return_value=None),
|
|
616
|
+
):
|
|
617
|
+
capture.capture_command("cmd1", "/tmp", 0, 100)
|
|
618
|
+
capture.capture_command("cmd2", "/tmp", 0, 100)
|
|
619
|
+
assert mock_spawn.call_count == 0
|
|
620
|
+
|
|
621
|
+
capture.capture_command("cmd3", "/tmp", 0, 100)
|
|
622
|
+
assert mock_spawn.call_count == 1
|
|
623
|
+
|
|
624
|
+
# Counter reset, so next 3 should trigger again
|
|
625
|
+
capture.capture_command("cmd4", "/tmp", 0, 100)
|
|
626
|
+
capture.capture_command("cmd5", "/tmp", 0, 100)
|
|
627
|
+
assert mock_spawn.call_count == 1
|
|
628
|
+
|
|
629
|
+
capture.capture_command("cmd6", "/tmp", 0, 100)
|
|
630
|
+
assert mock_spawn.call_count == 2
|
|
631
|
+
|
|
632
|
+
|
|
527
633
|
# ---------------------------------------------------------------------------
|
|
528
634
|
# Test cases: Session summary generation
|
|
529
635
|
# ---------------------------------------------------------------------------
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|