deja-cli 0.2.0__tar.gz → 0.2.1__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.
Files changed (61) hide show
  1. {deja_cli-0.2.0 → deja_cli-0.2.1}/PKG-INFO +1 -1
  2. {deja_cli-0.2.0 → deja_cli-0.2.1}/deja/core/extractor.py +19 -9
  3. {deja_cli-0.2.0 → deja_cli-0.2.1}/deja/core/reflection.py +86 -19
  4. {deja_cli-0.2.0 → deja_cli-0.2.1}/deja/core/store/__init__.py +26 -0
  5. {deja_cli-0.2.0 → deja_cli-0.2.1}/deja/core/store/policy.py +6 -3
  6. {deja_cli-0.2.0 → deja_cli-0.2.1}/deja/core/store/repos/memories.py +17 -0
  7. {deja_cli-0.2.0 → deja_cli-0.2.1}/deja/interfaces/cli/backfill.py +23 -3
  8. {deja_cli-0.2.0 → deja_cli-0.2.1}/deja/interfaces/cli/maintenance.py +6 -0
  9. {deja_cli-0.2.0 → deja_cli-0.2.1}/deja/interfaces/cli/session.py +20 -4
  10. {deja_cli-0.2.0 → deja_cli-0.2.1}/deja/interfaces/cli/setup.py +25 -7
  11. {deja_cli-0.2.0 → deja_cli-0.2.1}/deja/interfaces/mcp_server.py +20 -3
  12. {deja_cli-0.2.0 → deja_cli-0.2.1}/pyproject.toml +1 -1
  13. {deja_cli-0.2.0 → deja_cli-0.2.1}/.github/workflows/ci.yml +0 -0
  14. {deja_cli-0.2.0 → deja_cli-0.2.1}/.gitignore +0 -0
  15. {deja_cli-0.2.0 → deja_cli-0.2.1}/LICENSE +0 -0
  16. {deja_cli-0.2.0 → deja_cli-0.2.1}/README.pypi.md +0 -0
  17. {deja_cli-0.2.0 → deja_cli-0.2.1}/config/default.yaml +0 -0
  18. {deja_cli-0.2.0 → deja_cli-0.2.1}/deja/__init__.py +0 -0
  19. {deja_cli-0.2.0 → deja_cli-0.2.1}/deja/cloud.py +0 -0
  20. {deja_cli-0.2.0 → deja_cli-0.2.1}/deja/config.py +0 -0
  21. {deja_cli-0.2.0 → deja_cli-0.2.1}/deja/core/__init__.py +0 -0
  22. {deja_cli-0.2.0 → deja_cli-0.2.1}/deja/core/store/_helpers.py +0 -0
  23. {deja_cli-0.2.0 → deja_cli-0.2.1}/deja/core/store/_schema.py +0 -0
  24. {deja_cli-0.2.0 → deja_cli-0.2.1}/deja/core/store/connection.py +0 -0
  25. {deja_cli-0.2.0 → deja_cli-0.2.1}/deja/core/store/model.py +0 -0
  26. {deja_cli-0.2.0 → deja_cli-0.2.1}/deja/core/store/queries.py +0 -0
  27. {deja_cli-0.2.0 → deja_cli-0.2.1}/deja/core/store/repos/__init__.py +0 -0
  28. {deja_cli-0.2.0 → deja_cli-0.2.1}/deja/core/store/repos/observations.py +0 -0
  29. {deja_cli-0.2.0 → deja_cli-0.2.1}/deja/core/store/repos/reflection.py +0 -0
  30. {deja_cli-0.2.0 → deja_cli-0.2.1}/deja/core/store/services/__init__.py +0 -0
  31. {deja_cli-0.2.0 → deja_cli-0.2.1}/deja/core/store/services/load.py +0 -0
  32. {deja_cli-0.2.0 → deja_cli-0.2.1}/deja/core/store/services/maintenance.py +0 -0
  33. {deja_cli-0.2.0 → deja_cli-0.2.1}/deja/core/store/services/ranking.py +0 -0
  34. {deja_cli-0.2.0 → deja_cli-0.2.1}/deja/core/store/services/save.py +0 -0
  35. {deja_cli-0.2.0 → deja_cli-0.2.1}/deja/core/store/services/search.py +0 -0
  36. {deja_cli-0.2.0 → deja_cli-0.2.1}/deja/ingest/__init__.py +0 -0
  37. {deja_cli-0.2.0 → deja_cli-0.2.1}/deja/ingest/watchers/__init__.py +0 -0
  38. {deja_cli-0.2.0 → deja_cli-0.2.1}/deja/ingest/watchers/base.py +0 -0
  39. {deja_cli-0.2.0 → deja_cli-0.2.1}/deja/ingest/watchers/claude_code.py +0 -0
  40. {deja_cli-0.2.0 → deja_cli-0.2.1}/deja/ingest/watchers/codex_cli.py +0 -0
  41. {deja_cli-0.2.0 → deja_cli-0.2.1}/deja/ingest/watchers/gemini_cli.py +0 -0
  42. {deja_cli-0.2.0 → deja_cli-0.2.1}/deja/interfaces/__init__.py +0 -0
  43. {deja_cli-0.2.0 → deja_cli-0.2.1}/deja/interfaces/cli/__init__.py +0 -0
  44. {deja_cli-0.2.0 → deja_cli-0.2.1}/deja/interfaces/cli/_helpers.py +0 -0
  45. {deja_cli-0.2.0 → deja_cli-0.2.1}/deja/interfaces/cli/cloud.py +0 -0
  46. {deja_cli-0.2.0 → deja_cli-0.2.1}/deja/interfaces/cli/memory.py +0 -0
  47. {deja_cli-0.2.0 → deja_cli-0.2.1}/deja/interfaces/cli/transfer.py +0 -0
  48. {deja_cli-0.2.0 → deja_cli-0.2.1}/deja/interfaces/cli/watch.py +0 -0
  49. {deja_cli-0.2.0 → deja_cli-0.2.1}/deja/interfaces/web.py +0 -0
  50. {deja_cli-0.2.0 → deja_cli-0.2.1}/deja/interfaces/web_ui/index.html +0 -0
  51. {deja_cli-0.2.0 → deja_cli-0.2.1}/deja/llm/__init__.py +0 -0
  52. {deja_cli-0.2.0 → deja_cli-0.2.1}/deja/llm/base.py +0 -0
  53. {deja_cli-0.2.0 → deja_cli-0.2.1}/deja/llm/embedding.py +0 -0
  54. {deja_cli-0.2.0 → deja_cli-0.2.1}/deja/llm/factory.py +0 -0
  55. {deja_cli-0.2.0 → deja_cli-0.2.1}/deja/llm/providers/__init__.py +0 -0
  56. {deja_cli-0.2.0 → deja_cli-0.2.1}/deja/llm/providers/anthropic.py +0 -0
  57. {deja_cli-0.2.0 → deja_cli-0.2.1}/deja/llm/providers/ollama.py +0 -0
  58. {deja_cli-0.2.0 → deja_cli-0.2.1}/deja/main.py +0 -0
  59. {deja_cli-0.2.0 → deja_cli-0.2.1}/hooks/deja-post-fail.sh +0 -0
  60. {deja_cli-0.2.0 → deja_cli-0.2.1}/hooks/deja-precompact.sh +0 -0
  61. {deja_cli-0.2.0 → deja_cli-0.2.1}/hooks/deja-recall.sh +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: deja-cli
3
- Version: 0.2.0
3
+ Version: 0.2.1
4
4
  Summary: Local-first persistent memory CLI for coding agents
5
5
  Author-email: Mike <mike@bigtreeproduction.com>
6
6
  License: MIT
@@ -97,15 +97,25 @@ async def extract_memories(
97
97
 
98
98
  user_prompt = f"Session transcript/summary to extract memories from:\n\n{transcript}"
99
99
 
100
- try:
101
- result = await adapter.complete_structured(
102
- system=EXTRACTION_SYSTEM,
103
- user=user_prompt,
104
- schema=EXTRACTION_SCHEMA,
105
- )
106
- except Exception as e:
107
- logger.error("Extraction LLM error: %s", e)
108
- return []
100
+ # Bug R2 (2026-04-22): let adapter / transport / JSON-parse errors
101
+ # propagate. The previous ``except Exception: return []`` made every
102
+ # LLM outage indistinguishable from "the model had nothing to
103
+ # extract." The watcher's ``_process`` then treated the empty list
104
+ # as a successful extraction and stamped ``_processed[path]`` —
105
+ # silently burning the transcript. Same bug class as H5 on the read
106
+ # side. Callers decide:
107
+ #
108
+ # - Watcher (``deja/ingest/watchers/base.py``) wraps this call in
109
+ # its own try/except and routes to ``_schedule_extraction_retry``
110
+ # (bounded N8/P2 backoff: 30s / 2min / 10min, then give up loudly).
111
+ # - CLI callers (``deja save-session``, ``deja ingest-skills``,
112
+ # ``deja backfill``) catch and either exit cleanly or log + skip
113
+ # the one file and continue.
114
+ result = await adapter.complete_structured(
115
+ system=EXTRACTION_SYSTEM,
116
+ user=user_prompt,
117
+ schema=EXTRACTION_SCHEMA,
118
+ )
109
119
 
110
120
  memories = result.get("memories", [])
111
121
  if not isinstance(memories, list):
@@ -336,6 +336,37 @@ class ReflectionEngine:
336
336
  await self.store.set_reflection_meta(None, last_archive_at=_now_iso())
337
337
  return count
338
338
 
339
+ async def run_dedup_fuzzy(self, project: Optional[str] = None) -> dict:
340
+ """Bug R4 (2026-04-22): wire fuzzy dedup into scheduled reflection.
341
+
342
+ ``MaintenanceService.dedup_fuzzy`` has shipped since the Phase 7
343
+ restructure but was never invoked from ``run_full`` — while
344
+ docstrings (MemoryStore, SaveService, MaintenanceService) and
345
+ user-facing docs (code-reading-guide, plan.md, AGENTS.md,
346
+ GEMINI.md, repo-strategic-assessment) all asserted it ran at
347
+ reflection time. Save-side dedup stayed exact-match only, so
348
+ two-character edits accumulated forever and the vault's
349
+ top-ranked results filled with near-duplicates over time.
350
+ Returns ``{"merged": N, "archived": N}``.
351
+
352
+ When ``project is None``, fan out per-project + globals (mirrors
353
+ ``run_reflector(None)``'s N2 pattern). The underlying service's
354
+ ``dedup_fuzzy(project=None)`` touches only the global bucket
355
+ because ``fetch_active(None)`` is global-only by convention —
356
+ so a single call would silently leave every project-scoped
357
+ near-duplicate unmerged. Fanning out keeps the scheduled pass
358
+ honest.
359
+ """
360
+ if project is not None:
361
+ return await self.store.dedup_fuzzy(project=project)
362
+ merged = 0
363
+ archived = 0
364
+ for p in await self.store.list_memory_projects():
365
+ result = await self.store.dedup_fuzzy(project=p)
366
+ merged += result.get("merged", 0)
367
+ archived += result.get("archived", 0)
368
+ return {"merged": merged, "archived": archived}
369
+
339
370
  # ── Agent mode ─────────────────────────────────────────────────────────
340
371
 
341
372
  async def agent_mode_prompt(self, project: Optional[str] = None) -> str:
@@ -354,26 +385,53 @@ class ReflectionEngine:
354
385
  lines = [
355
386
  f"You are acting as a memory reflector for project '{project_label}'.",
356
387
  "",
357
- f"Review the {len(memories)} active memories below and identify any that should be:",
358
- f" 1. Archived (stale, no longer relevant):",
359
- f" deja archive <id>",
360
- f" 2. Invalidated (contradicted by newer information):",
361
- f" deja invalidate <id>",
362
- f" 3. Consolidated (two memories express the same thing):",
363
- f" deja archive <id1>",
364
- f" deja archive <id2>",
388
+ f"PROCESS EVERY ONE of the {len(memories)} active memories below not just",
389
+ "whatever topic the user happened to ask about in this session. The default",
390
+ "mode of `deja reflect --agent-mode` is a complete sweep: comb every row,",
391
+ "build a punch list of all obvious issues, then execute the punch list in",
392
+ "one pass.",
393
+ "",
394
+ "Scan for, in roughly this priority order:",
395
+ " - Exact-content duplicates (same words across multiple IDs — pick the",
396
+ " one with highest reuse_count, archive the rest)",
397
+ " - Scope-leaks (same content saved at scope:global AND scope:project:X —",
398
+ " keep the correctly-scoped one, archive the other)",
399
+ " - Junk / save errors (single-word patterns, truncated content, malformed",
400
+ " entries)",
401
+ " - Stale stub TODO lists ('next items: 1. X, 2. Y') — typically archive",
402
+ " - Untriggered gotchas tied to a specific command boundary (add --trigger)",
403
+ " - Misclassified entries (pattern that is really a gotcha; gotcha that is",
404
+ " really a preference)",
405
+ " - Semantic duplicates (same rule, different wording — fuzzy threshold",
406
+ " won't catch these, you must)",
407
+ "",
408
+ "Actions:",
409
+ " 1. Archive (stale, no longer relevant):",
410
+ " deja archive <id>",
411
+ " 2. Invalidate (actively contradicted by newer information):",
412
+ " deja invalidate <id>",
413
+ " 3. Consolidate (two or more memories express the same thing):",
414
+ " deja archive <id1>",
415
+ " deja archive <id2>",
365
416
  f' deja save "<condensed content>" --type <type>{project_flag}',
366
- f" 4. Trigger-tagged (gotcha clearly tied to a specific command but has no trigger):",
367
- f' deja update <id> --trigger "cmd1, cmd2"',
368
- f" Use this for gotchas about what to do right before/after a specific command.",
369
- f" Example triggers: 'kubectl apply', 'alembic upgrade', 'terraform apply'.",
370
- f" Only tag gotchas not preferences, decisions, or progress.",
371
- f" 5. Reclassified (saved as the wrong type — e.g. pattern that is really a gotcha):",
372
- f" deja update <id> --type gotcha",
417
+ " (If one existing version is clearly better, just archive the lesser",
418
+ " and keep the better as-is — no new save needed.)",
419
+ " 4. Trigger-tag (gotcha clearly tied to a specific command, no trigger yet):",
420
+ ' deja update <id> --trigger "cmd1, cmd2"',
421
+ " Use this for gotchas about what to do right before/after a specific",
422
+ " command. Example triggers: 'kubectl apply', 'alembic upgrade',",
423
+ " 'terraform apply'. Only tag gotchas — not preferences, decisions,",
424
+ " or progress.",
425
+ " 5. Reclassify (saved as the wrong type — e.g. pattern that is really a",
426
+ " gotcha):",
427
+ " deja update <id> --type gotcha",
373
428
  "",
374
- "Be conservative only act on memories that clearly need attention.",
375
- "For trigger tagging: if a gotcha is already tagged (shown as [trigger:...]), skip it.",
376
- "If everything looks good, do nothing.",
429
+ "Be conservative on each ACTION (skip a memory when intent is unclear),",
430
+ "but exhaustive on COVERAGE (visit every memory, not just user-flagged ones).",
431
+ "For trigger tagging: if a gotcha is already tagged (shown as [trigger:...]),",
432
+ "skip it.",
433
+ f"If after sweeping all {len(memories)} you find nothing actionable, that's",
434
+ "fine — say so.",
377
435
  "",
378
436
  "--- MEMORIES ---",
379
437
  "",
@@ -395,7 +453,15 @@ class ReflectionEngine:
395
453
  # ── Full pass + auto-trigger ────────────────────────────────────────────
396
454
 
397
455
  async def run_full(self, project: Optional[str] = None) -> dict:
398
- """Full reflection pass: observer → reflector → decay → promote → archive."""
456
+ """Full reflection pass: observer → reflector → decay → promote → dedup_fuzzy → archive.
457
+
458
+ Bug R4 (2026-04-22): ``dedup_fuzzy`` slots between ``promote``
459
+ and ``archive`` — after promotion (so promoted patterns are in
460
+ the active set for cross-project dedup) and before archival (so
461
+ archive sweeps low-confidence rows AFTER near-duplicates have
462
+ been merged, giving the survivor the full merged confidence/
463
+ reuse bump).
464
+ """
399
465
  results: dict = {}
400
466
  if self.adapter:
401
467
  results["observer"] = await self.run_observer(project)
@@ -405,6 +471,7 @@ class ReflectionEngine:
405
471
  results["reflector"] = 0
406
472
  results["decay"] = await self.run_decay()
407
473
  results["promote"] = await self.run_promote()
474
+ results["dedup_fuzzy"] = await self.run_dedup_fuzzy(project)
408
475
  results["archive"] = await self.run_archive()
409
476
  return results
410
477
 
@@ -267,6 +267,21 @@ class MemoryStore:
267
267
  async def archive_below_threshold(self, threshold: float) -> int:
268
268
  return await self._maint().archive_below_threshold(threshold)
269
269
 
270
+ async def dedup_fuzzy(
271
+ self, project: Optional[str] = None, threshold: Optional[float] = None
272
+ ) -> dict:
273
+ """Token-overlap dedup across active memories for the scope.
274
+
275
+ Merges near-duplicates (overlap > ``threshold``) into the older
276
+ row and archives the newer — preserves audit trail via archive,
277
+ never deletes. Returns ``{"merged": N, "archived": N}``.
278
+ Idempotent. See ``MaintenanceService.dedup_fuzzy``.
279
+ """
280
+ # Keep policy default in the service so the facade stays thin.
281
+ if threshold is None:
282
+ return await self._maint().dedup_fuzzy(project=project)
283
+ return await self._maint().dedup_fuzzy(project=project, threshold=threshold)
284
+
270
285
  async def increment_reuse_count(self, project: Optional[str] = None) -> int:
271
286
  return await self._maint().increment_reuse_count(project)
272
287
 
@@ -544,6 +559,17 @@ class MemoryStore:
544
559
  db = await self._connection.get()
545
560
  return await self._obs().list_distinct_projects(db)
546
561
 
562
+ async def list_memory_projects(self) -> list[Optional[str]]:
563
+ """Distinct project values with at least one active memory.
564
+
565
+ Used by ``ReflectionEngine.run_dedup_fuzzy(None)`` (Bug R4,
566
+ 2026-04-22) to fan dedup out across every scope instead of
567
+ touching only globals (the historical ``dedup_fuzzy(None)``
568
+ semantics).
569
+ """
570
+ db = await self._connection.get()
571
+ return await self._mem().list_active_projects(db)
572
+
547
573
  async def replace_observations(
548
574
  self, project: Optional[str], new_texts: list[str],
549
575
  *,
@@ -12,9 +12,12 @@ from __future__ import annotations
12
12
 
13
13
  # ── dedup ──────────────────────────────────────────────────────────────────────
14
14
 
15
- # On save, a candidate memory with token-overlap above this ratio against
16
- # an existing memory of the same type+scope is treated as a duplicate:
17
- # instead of inserting, we bump the existing row's confidence.
15
+ # At reflection time (MaintenanceService.dedup_fuzzy ReflectionEngine.run_full),
16
+ # a candidate memory with token-overlap above this ratio against an existing
17
+ # memory of the same type+scope+project is treated as a near-duplicate: the
18
+ # survivor's confidence and reuse_count are bumped, the loser is archived.
19
+ # Save-time dedup is exact-match only (idx_memories_dedup composite index)
20
+ # to keep the hot path <1ms; fuzzy work batches into reflection (R4, 2026-04-22).
18
21
  DEDUP_OVERLAP_THRESHOLD: float = 0.8
19
22
 
20
23
  # How much to add to an existing memory's confidence when a dedup match
@@ -117,6 +117,23 @@ class MemoryRepository:
117
117
  rows = await cur.fetchall()
118
118
  return [Memory.from_row(r) for r in rows]
119
119
 
120
+ async def list_active_projects(
121
+ self, db: aiosqlite.Connection
122
+ ) -> list[Optional[str]]:
123
+ """Distinct ``project`` values with at least one active memory.
124
+
125
+ Includes ``None`` (global bucket) iff any active global rows
126
+ exist. Parallels ``ObservationRepository.list_distinct_projects``
127
+ and is used by ``ReflectionEngine.run_dedup_fuzzy(None)`` (Bug
128
+ R4, 2026-04-22) to fan out dedup across every scope instead of
129
+ operating only on globals.
130
+ """
131
+ async with db.execute(
132
+ f"SELECT DISTINCT project FROM memories WHERE {ACTIVE_FILTER}"
133
+ ) as cur:
134
+ rows = await cur.fetchall()
135
+ return [r["project"] for r in rows]
136
+
120
137
  async def fetch_for_sync(self, db: aiosqlite.Connection) -> list[Memory]:
121
138
  """Active + archived memories across every scope, for cloud sync push.
122
139
 
@@ -275,7 +275,15 @@ def register(app: typer.Typer) -> None:
275
275
  typer.echo(f" [dry-run] summary.md ({len(content)} chars)")
276
276
  continue
277
277
 
278
- memories = await extract_memories(content, proj_name, "backfill", adapter)
278
+ try:
279
+ memories = await extract_memories(content, proj_name, "backfill", adapter)
280
+ except Exception as e:
281
+ # Bug R2 (2026-04-22): extract_memories now raises on
282
+ # adapter / transport failures. Log + skip this one
283
+ # source so a transient LLM blip doesn't abort the
284
+ # whole backfill run.
285
+ typer.echo(f" summary.md (extraction failed: {e}, skipped)", err=True)
286
+ continue
279
287
  total_memories += await _embed_and_save(memories, store, embedding_adapter)
280
288
  typer.echo(f" summary.md → {len(memories)} memories")
281
289
 
@@ -290,7 +298,13 @@ def register(app: typer.Typer) -> None:
290
298
  typer.echo(f" [dry-run] sessions-index.json ({entry_count} sessions)")
291
299
  else:
292
300
  entry_count = len(json.loads(index_file.read_text()).get("entries", []))
293
- memories = await extract_memories(transcript, proj_name, "backfill", adapter)
301
+ try:
302
+ memories = await extract_memories(transcript, proj_name, "backfill", adapter)
303
+ except Exception as e:
304
+ # Bug R2 (2026-04-22): skip this source on
305
+ # extraction failure instead of aborting.
306
+ typer.echo(f" sessions-index.json (extraction failed: {e}, skipped)", err=True)
307
+ continue
294
308
  total_memories += await _embed_and_save(memories, store, embedding_adapter)
295
309
  typer.echo(f" sessions-index.json ({entry_count} sessions) → {len(memories)} memories")
296
310
 
@@ -309,7 +323,13 @@ def register(app: typer.Typer) -> None:
309
323
  typer.echo(f" [dry-run] {jsonl_file.name} ({len(transcript)} chars)")
310
324
  continue
311
325
 
312
- memories = await extract_memories(transcript, proj_name, "backfill", adapter)
326
+ try:
327
+ memories = await extract_memories(transcript, proj_name, "backfill", adapter)
328
+ except Exception as e:
329
+ # Bug R2 (2026-04-22): skip this jsonl on
330
+ # extraction failure; keep processing others.
331
+ typer.echo(f" {jsonl_file.name} (extraction failed: {e}, skipped)", err=True)
332
+ continue
313
333
  total_memories += await _embed_and_save(memories, store, embedding_adapter)
314
334
  typer.echo(f" {jsonl_file.name} → {len(memories)} memories")
315
335
 
@@ -61,6 +61,12 @@ def register(app: typer.Typer) -> None:
61
61
  lines.append(f" Decay: {results['decay']} memories decayed")
62
62
  if results.get("promote"):
63
63
  lines.append(f" Promote: {results['promote']} patterns promoted to global")
64
+ dedup = results.get("dedup_fuzzy") or {}
65
+ if dedup.get("merged") or dedup.get("archived"):
66
+ lines.append(
67
+ f" Dedup: {dedup.get('merged', 0)} merged, "
68
+ f"{dedup.get('archived', 0)} near-duplicates archived"
69
+ )
64
70
  if results.get("archive"):
65
71
  lines.append(f" Archive: {results['archive']} memories archived")
66
72
  if len(lines) == 1:
@@ -122,9 +122,17 @@ def register(app: typer.Typer) -> None:
122
122
  await store.init_db()
123
123
  try:
124
124
  from deja.core.extractor import extract_memories
125
- memories = await extract_memories(
126
- content, project or "unknown", "save-session", adapter
127
- )
125
+ # Bug R2 (2026-04-22): extract_memories now raises on
126
+ # transport / adapter / JSON-parse failures instead of
127
+ # returning []. Catch and exit cleanly so the user sees
128
+ # the real cause instead of a bare traceback.
129
+ try:
130
+ memories = await extract_memories(
131
+ content, project or "unknown", "save-session", adapter
132
+ )
133
+ except Exception as e:
134
+ typer.echo(f"Extraction failed: {e}", err=True)
135
+ raise typer.Exit(1)
128
136
  embedding_adapter = await create_embedding_adapter(config)
129
137
  saved = await _embed_and_save(memories, store, embedding_adapter)
130
138
  return saved
@@ -186,7 +194,15 @@ def register(app: typer.Typer) -> None:
186
194
  from deja.core.extractor import extract_memories
187
195
  return await extract_memories(content, project or "unknown", "ingest-skills", adapter)
188
196
 
189
- memories = asyncio.run(_extract())
197
+ # Bug R2 (2026-04-22): extract_memories propagates adapter
198
+ # errors; catch and exit cleanly with the error message.
199
+ try:
200
+ memories = asyncio.run(_extract())
201
+ except typer.Exit:
202
+ raise
203
+ except Exception as e:
204
+ typer.echo(f"Extraction failed: {e}", err=True)
205
+ raise typer.Exit(1)
190
206
  if not memories:
191
207
  typer.echo("Extraction returned no memories. Try --no-llm or check the file format.", err=True)
192
208
  raise typer.Exit(1)
@@ -359,6 +359,31 @@ def _detect_deja_setup(target_path: Path, agent: str) -> list[str]:
359
359
 
360
360
  def _install_claude_hooks() -> None:
361
361
  """Write hook scripts and register them in ~/.claude/settings.json."""
362
+ # Bug R1 (2026-04-22): validate settings.json BEFORE any disk writes.
363
+ # Previously this function parsed settings.json with
364
+ # ``except json.JSONDecodeError: settings = {}`` then overwrote the
365
+ # file. A user with any syntax error (stray comma, unmatched
366
+ # bracket, half-finished hand edit) lost every other hook,
367
+ # permission rule, MCP server registration, env var, and status-line
368
+ # entry they had configured — replaced with just Deja's four hook
369
+ # entries, with zero log or prompt. Refuse loudly instead; the user
370
+ # fixes the syntax error and re-runs. The on-disk bytes stay
371
+ # untouched on the failure path.
372
+ settings_path = Path("~/.claude/settings.json").expanduser()
373
+ settings_text = settings_path.read_text(encoding="utf-8") if settings_path.exists() else ""
374
+ if settings_text.strip():
375
+ try:
376
+ settings = json.loads(settings_text)
377
+ except json.JSONDecodeError as e:
378
+ typer.echo(
379
+ f"deja: refusing to proceed — ~/.claude/settings.json is not valid "
380
+ f"JSON ({e}). Fix the syntax error and re-run `deja setup claude-code`.",
381
+ err=True,
382
+ )
383
+ raise typer.Exit(1)
384
+ else:
385
+ settings = {}
386
+
362
387
  hooks_dir = Path("~/.claude/hooks").expanduser()
363
388
  hooks_dir.mkdir(parents=True, exist_ok=True)
364
389
 
@@ -378,13 +403,6 @@ def _install_claude_hooks() -> None:
378
403
  display = str(path).replace(str(Path.home()), "~")
379
404
  typer.echo(f"deja: hooks installed → {display}")
380
405
 
381
- settings_path = Path("~/.claude/settings.json").expanduser()
382
- settings_text = settings_path.read_text(encoding="utf-8") if settings_path.exists() else ""
383
- try:
384
- settings = json.loads(settings_text) if settings_text.strip() else {}
385
- except json.JSONDecodeError:
386
- settings = {}
387
-
388
406
  hooks_obj = settings.setdefault("hooks", {})
389
407
  changed = False
390
408
 
@@ -109,15 +109,32 @@ async def memory_save(
109
109
  "limit": e.limit,
110
110
  })
111
111
 
112
+ # Bug R3 (2026-04-22): surface the cloud-push outcome to the agent.
113
+ # Previously the ``(ok, msg)`` tuple returned by ``push_memory`` was
114
+ # discarded and the response was always ``{"status": "saved"}`` —
115
+ # so an agent with an expired PAT saw every save "succeed" while
116
+ # memories silently piled up local-only until the user ran
117
+ # ``deja sync`` by hand. The agent now sees ``cloud`` in
118
+ # {"ok", "skipped", "failed"} and can react (surface to user,
119
+ # prompt re-login) instead of trusting a misleading success.
120
+ cloud_status = "skipped"
121
+ cloud_reason: Optional[str] = None
112
122
  if config.cloud.sync_on_save:
113
123
  from deja.cloud import get_token, push_memory
114
124
  if get_token(config):
115
125
  # Bug N5 (2026-04-19): trigger → triggerCmds translation now lives
116
126
  # in ``_sanitize_for_push``; eager push delegates to the same path
117
127
  # as batch ``sync_push`` so the three codepaths can't drift again.
118
- push_memory(result.cloud_data, config)
119
-
120
- return json.dumps({"id": result.id, "status": "saved"})
128
+ ok, msg = push_memory(result.cloud_data, config)
129
+ cloud_status = "ok" if ok else "failed"
130
+ cloud_reason = None if ok else msg
131
+
132
+ return json.dumps({
133
+ "id": result.id,
134
+ "status": "saved",
135
+ "cloud": cloud_status,
136
+ "cloud_reason": cloud_reason,
137
+ })
121
138
 
122
139
 
123
140
  @mcp.tool()
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "deja-cli"
3
- version = "0.2.0"
3
+ version = "0.2.1"
4
4
  description = "Local-first persistent memory CLI for coding agents"
5
5
  readme = "README.pypi.md"
6
6
  license = { text = "MIT" }
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