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.

Files changed (32) hide show
  1. {cli_mem-0.3.5 → cli_mem-0.3.6}/PKG-INFO +1 -1
  2. {cli_mem-0.3.5 → cli_mem-0.3.6}/pyproject.toml +1 -1
  3. {cli_mem-0.3.5 → cli_mem-0.3.6}/src/mem/__init__.py +1 -1
  4. {cli_mem-0.3.5 → cli_mem-0.3.6}/src/mem/capture.py +26 -0
  5. {cli_mem-0.3.5 → cli_mem-0.3.6}/src/mem/cli.py +13 -21
  6. {cli_mem-0.3.5 → cli_mem-0.3.6}/src/mem/models.py +1 -0
  7. {cli_mem-0.3.5 → cli_mem-0.3.6}/src/mem/patterns.py +65 -8
  8. {cli_mem-0.3.5 → cli_mem-0.3.6}/src/mem/storage.py +30 -0
  9. {cli_mem-0.3.5 → cli_mem-0.3.6}/tests/conftest.py +4 -0
  10. {cli_mem-0.3.5 → cli_mem-0.3.6}/tests/test_patterns.py +107 -1
  11. {cli_mem-0.3.5 → cli_mem-0.3.6}/.github/workflows/ci.yml +0 -0
  12. {cli_mem-0.3.5 → cli_mem-0.3.6}/.github/workflows/release.yml +0 -0
  13. {cli_mem-0.3.5 → cli_mem-0.3.6}/.gitignore +0 -0
  14. {cli_mem-0.3.5 → cli_mem-0.3.6}/ARCHITECTURE.md +0 -0
  15. {cli_mem-0.3.5 → cli_mem-0.3.6}/LICENSE +0 -0
  16. {cli_mem-0.3.5 → cli_mem-0.3.6}/PHILOSOPHY.md +0 -0
  17. {cli_mem-0.3.5 → cli_mem-0.3.6}/README.md +0 -0
  18. {cli_mem-0.3.5 → cli_mem-0.3.6}/assets/.gitkeep +0 -0
  19. {cli_mem-0.3.5 → cli_mem-0.3.6}/docs/decisions/001-jsonl-over-sqlite.md +0 -0
  20. {cli_mem-0.3.5 → cli_mem-0.3.6}/docs/decisions/002-apple-fm-sdk-for-patterns.md +0 -0
  21. {cli_mem-0.3.5 → cli_mem-0.3.6}/docs/decisions/003-no-daemon.md +0 -0
  22. {cli_mem-0.3.5 → cli_mem-0.3.6}/docs/decisions/004-per-repo-jsonl.md +0 -0
  23. {cli_mem-0.3.5 → cli_mem-0.3.6}/hooks/mem.zsh +0 -0
  24. {cli_mem-0.3.5 → cli_mem-0.3.6}/install.sh +0 -0
  25. {cli_mem-0.3.5 → cli_mem-0.3.6}/src/mem/_generable.py +0 -0
  26. {cli_mem-0.3.5 → cli_mem-0.3.6}/src/mem/groups.py +0 -0
  27. {cli_mem-0.3.5 → cli_mem-0.3.6}/src/mem/search.py +0 -0
  28. {cli_mem-0.3.5 → cli_mem-0.3.6}/src/mem/variables.py +0 -0
  29. {cli_mem-0.3.5 → cli_mem-0.3.6}/tests/test_capture.py +0 -0
  30. {cli_mem-0.3.5 → cli_mem-0.3.6}/tests/test_groups.py +0 -0
  31. {cli_mem-0.3.5 → cli_mem-0.3.6}/tests/test_search.py +0 -0
  32. {cli_mem-0.3.5 → cli_mem-0.3.6}/tests/test_storage.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: cli-mem
3
- Version: 0.3.5
3
+ Version: 0.3.6
4
4
  Summary: Privacy-first CLI that turns shell history into searchable memory
5
5
  Project-URL: GitHub, https://github.com/matinsaurralde/mem
6
6
  Author: Matias Insaurralde
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "cli-mem"
7
- version = "0.3.5"
7
+ version = "0.3.6"
8
8
  description = "Privacy-first CLI that turns shell history into searchable memory"
9
9
  license = "MIT"
10
10
  readme = "README.md"
@@ -1,3 +1,3 @@
1
1
  """mem — your shell history, understood."""
2
2
 
3
- __version__ = "0.3.5"
3
+ __version__ = "0.3.6"
@@ -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
- @click.option("--keep-commands", type=int, default=90, help="Days to retain commands")
214
- @click.option("--keep-sessions", type=int, default=30, help="Days to retain sessions")
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
- console.print(f"Patterns: {new} new, {updated} updated\n")
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
- # Rotation
231
- cmd_removed, sess_removed = storage.rotate(keep_commands, keep_sessions)
232
- console.print("Rotation:")
233
- console.print(f" Commands older than {keep_commands}d: {cmd_removed} removed")
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()
@@ -44,6 +44,7 @@ class PatternFile(BaseModel):
44
44
  tool: str = Field(min_length=1)
45
45
  patterns: list[CommandPattern]
46
46
  last_updated: int
47
+ processed_commands: list[str] = []
47
48
 
48
49
 
49
50
  class WorkSession(BaseModel):
@@ -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, commands: list[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 — semantic understanding)
138
- cmd_to_pattern = await _generalize_commands(tool, unique_cmds)
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
- commands = [
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(commands) < 5:
244
+ if len(all_commands) < 5:
210
245
  return # Not enough data for meaningful patterns
211
246
 
212
- result = asyncio.run(extract_patterns_for_tool(tool, commands))
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