deja-cli 0.1.0__tar.gz → 0.1.3__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 (46) hide show
  1. {deja_cli-0.1.0 → deja_cli-0.1.3}/PKG-INFO +1 -1
  2. {deja_cli-0.1.0 → deja_cli-0.1.3}/config/default.yaml +6 -0
  3. deja_cli-0.1.3/deja/cloud.py +212 -0
  4. {deja_cli-0.1.0 → deja_cli-0.1.3}/deja/config.py +8 -4
  5. {deja_cli-0.1.0 → deja_cli-0.1.3}/deja/core/reflection.py +1 -23
  6. {deja_cli-0.1.0 → deja_cli-0.1.3}/deja/core/store.py +74 -4
  7. deja_cli-0.1.3/deja/interfaces/cli/__init__.py +20 -0
  8. deja_cli-0.1.3/deja/interfaces/cli/_helpers.py +166 -0
  9. deja_cli-0.1.3/deja/interfaces/cli/backfill.py +322 -0
  10. deja_cli-0.1.3/deja/interfaces/cli/cloud.py +151 -0
  11. deja_cli-0.1.3/deja/interfaces/cli/maintenance.py +140 -0
  12. deja_cli-0.1.3/deja/interfaces/cli/memory.py +361 -0
  13. deja_cli-0.1.3/deja/interfaces/cli/session.py +219 -0
  14. deja_cli-0.1.3/deja/interfaces/cli/setup.py +494 -0
  15. deja_cli-0.1.3/deja/interfaces/cli/transfer.py +180 -0
  16. deja_cli-0.1.3/deja/interfaces/cli/watch.py +160 -0
  17. {deja_cli-0.1.0 → deja_cli-0.1.3}/deja/interfaces/mcp_server.py +30 -4
  18. deja_cli-0.1.3/hooks/deja-precompact.sh +20 -0
  19. {deja_cli-0.1.0 → deja_cli-0.1.3}/pyproject.toml +2 -1
  20. deja_cli-0.1.0/deja/core/scheduler.py +0 -65
  21. deja_cli-0.1.0/deja/interfaces/cli.py +0 -1967
  22. {deja_cli-0.1.0 → deja_cli-0.1.3}/.gitignore +0 -0
  23. {deja_cli-0.1.0 → deja_cli-0.1.3}/LICENSE +0 -0
  24. {deja_cli-0.1.0 → deja_cli-0.1.3}/README.pypi.md +0 -0
  25. {deja_cli-0.1.0 → deja_cli-0.1.3}/deja/__init__.py +0 -0
  26. {deja_cli-0.1.0 → deja_cli-0.1.3}/deja/core/__init__.py +0 -0
  27. {deja_cli-0.1.0 → deja_cli-0.1.3}/deja/core/extractor.py +0 -0
  28. {deja_cli-0.1.0 → deja_cli-0.1.3}/deja/ingest/__init__.py +0 -0
  29. {deja_cli-0.1.0 → deja_cli-0.1.3}/deja/ingest/watchers/__init__.py +0 -0
  30. {deja_cli-0.1.0 → deja_cli-0.1.3}/deja/ingest/watchers/base.py +0 -0
  31. {deja_cli-0.1.0 → deja_cli-0.1.3}/deja/ingest/watchers/claude_code.py +0 -0
  32. {deja_cli-0.1.0 → deja_cli-0.1.3}/deja/ingest/watchers/codex_cli.py +0 -0
  33. {deja_cli-0.1.0 → deja_cli-0.1.3}/deja/ingest/watchers/gemini_cli.py +0 -0
  34. {deja_cli-0.1.0 → deja_cli-0.1.3}/deja/interfaces/__init__.py +0 -0
  35. {deja_cli-0.1.0 → deja_cli-0.1.3}/deja/interfaces/web.py +0 -0
  36. {deja_cli-0.1.0 → deja_cli-0.1.3}/deja/interfaces/web_ui/index.html +0 -0
  37. {deja_cli-0.1.0 → deja_cli-0.1.3}/deja/llm/__init__.py +0 -0
  38. {deja_cli-0.1.0 → deja_cli-0.1.3}/deja/llm/base.py +0 -0
  39. {deja_cli-0.1.0 → deja_cli-0.1.3}/deja/llm/embedding.py +0 -0
  40. {deja_cli-0.1.0 → deja_cli-0.1.3}/deja/llm/factory.py +0 -0
  41. {deja_cli-0.1.0 → deja_cli-0.1.3}/deja/llm/providers/__init__.py +0 -0
  42. {deja_cli-0.1.0 → deja_cli-0.1.3}/deja/llm/providers/anthropic.py +0 -0
  43. {deja_cli-0.1.0 → deja_cli-0.1.3}/deja/llm/providers/ollama.py +0 -0
  44. {deja_cli-0.1.0 → deja_cli-0.1.3}/deja/main.py +0 -0
  45. {deja_cli-0.1.0 → deja_cli-0.1.3}/hooks/deja-post-fail.sh +0 -0
  46. {deja_cli-0.1.0 → deja_cli-0.1.3}/hooks/deja-recall.sh +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: deja-cli
3
- Version: 0.1.0
3
+ Version: 0.1.3
4
4
  Summary: Local-first persistent memory CLI for coding agents
5
5
  Author-email: Mike <mike@bigtreeproduction.com>
6
6
  License: MIT
@@ -60,3 +60,9 @@ watchers:
60
60
  codex_cli: false
61
61
  aider: false
62
62
  debounce_seconds: 30
63
+
64
+ cloud:
65
+ enabled: false # set to true after deja login
66
+ endpoint: https://api.deja.sh
67
+ web_url: https://www.deja.sh # browser login opens this — must be www, not apex (apex forwarding drops query params)
68
+ sync_on_save: false # push to cloud on every deja save (async, non-blocking)
@@ -0,0 +1,212 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import secrets
5
+ import stat
6
+ import threading
7
+ import webbrowser
8
+ from http.server import BaseHTTPRequestHandler, HTTPServer
9
+ from pathlib import Path
10
+ from typing import Optional
11
+ from urllib.parse import parse_qs, urlparse
12
+
13
+ import httpx
14
+
15
+ CLI_REDIRECT_PORT = 51234
16
+
17
+ AUTH_FILE = Path.home() / ".deja" / "auth.json"
18
+ SYNC_STATE_FILE = Path.home() / ".deja" / "sync_state.json"
19
+
20
+ DEFAULT_ENDPOINT = "https://api.deja.sh"
21
+
22
+
23
+ def _get_endpoint(config=None) -> str:
24
+ if config is None:
25
+ return DEFAULT_ENDPOINT
26
+ cloud = getattr(config, "cloud", None)
27
+ if cloud is None:
28
+ return DEFAULT_ENDPOINT
29
+ return getattr(cloud, "endpoint", DEFAULT_ENDPOINT).rstrip("/")
30
+
31
+
32
+ # ── Token storage ─────────────────────────────────────────────────────
33
+
34
+
35
+ def load_auth() -> Optional[dict]:
36
+ if not AUTH_FILE.exists():
37
+ return None
38
+ return json.loads(AUTH_FILE.read_text())
39
+
40
+
41
+ def save_auth(data: dict) -> None:
42
+ AUTH_FILE.parent.mkdir(exist_ok=True)
43
+ AUTH_FILE.write_text(json.dumps(data, indent=2))
44
+ AUTH_FILE.chmod(stat.S_IRUSR | stat.S_IWUSR) # 600
45
+
46
+
47
+ def clear_auth() -> None:
48
+ if AUTH_FILE.exists():
49
+ AUTH_FILE.unlink()
50
+
51
+
52
+ def get_token(config=None) -> Optional[str]:
53
+ """Return the stored PAT, or None if not logged in."""
54
+ auth = load_auth()
55
+ if not auth:
56
+ return None
57
+ return auth.get("access_token")
58
+
59
+
60
+ # ── Browser login flow ────────────────────────────────────────────────
61
+
62
+
63
+ def login_browser(config=None) -> str:
64
+ """Open deja.sh in the browser, wait for the CLI callback, exchange code for PAT."""
65
+ endpoint = _get_endpoint(config)
66
+ web_url = getattr(getattr(config, "cloud", None), "web_url", None) or endpoint.replace("api.", "www.", 1)
67
+
68
+ state = secrets.token_urlsafe(16)
69
+ code_holder: dict = {}
70
+ ready = threading.Event()
71
+
72
+ class Handler(BaseHTTPRequestHandler):
73
+ def do_GET(self):
74
+ qs = parse_qs(urlparse(self.path).query)
75
+ code_holder["code"] = qs.get("code", [None])[0]
76
+ code_holder["error"] = qs.get("error", [None])[0]
77
+ self.send_response(200)
78
+ self.end_headers()
79
+ self.wfile.write(
80
+ b"<html><body style='font-family:sans-serif;background:#09090b;color:#fff;"
81
+ b"display:flex;align-items:center;justify-content:center;height:100vh;margin:0'>"
82
+ b"<p>CLI authorized. Return to your terminal.</p>"
83
+ b"<script>window.close()</script></body></html>"
84
+ )
85
+ ready.set()
86
+
87
+ def log_message(self, *args):
88
+ pass
89
+
90
+ server = HTTPServer(("localhost", CLI_REDIRECT_PORT), Handler)
91
+
92
+ auth_url = f"{web_url}/auth/cli?state={state}&port={CLI_REDIRECT_PORT}"
93
+ webbrowser.open(auth_url)
94
+ print(f"Opening deja.sh in your browser…")
95
+ print(f"If it doesn't open, visit: {auth_url}")
96
+
97
+ server.handle_request()
98
+
99
+ if code_holder.get("error"):
100
+ raise RuntimeError(f"Auth error: {code_holder['error']}")
101
+ if not code_holder.get("code"):
102
+ raise RuntimeError("No auth code received")
103
+
104
+ # Exchange one-time code for a PAT
105
+ resp = httpx.post(
106
+ f"{endpoint}/auth/cli/exchange",
107
+ json={"code": code_holder["code"]},
108
+ timeout=10,
109
+ )
110
+ resp.raise_for_status()
111
+ return resp.json()["token"]
112
+
113
+
114
+ # ── Whoami ────────────────────────────────────────────────────────────
115
+
116
+
117
+ def whoami(config=None) -> Optional[dict]:
118
+ token = get_token(config)
119
+ if not token:
120
+ return None
121
+ endpoint = _get_endpoint(config)
122
+ resp = httpx.get(
123
+ f"{endpoint}/auth/me",
124
+ headers={"Authorization": f"Bearer {token}"},
125
+ timeout=10,
126
+ )
127
+ if not resp.is_success:
128
+ return None
129
+ return resp.json()
130
+
131
+
132
+ # ── Save to cloud ─────────────────────────────────────────────────────
133
+
134
+
135
+ _PUSH_FIELDS = {"content", "type", "project", "confidence", "triggerCmds", "category"}
136
+
137
+
138
+ def push_memory(memory: dict, config=None) -> bool:
139
+ """Push a single memory to cloud. Returns True on success, False on failure (non-fatal)."""
140
+ token = get_token(config)
141
+ if not token:
142
+ return False
143
+ endpoint = _get_endpoint(config)
144
+ payload = _sanitize_for_push(memory)
145
+ try:
146
+ resp = httpx.post(
147
+ f"{endpoint}/v1/memories",
148
+ json=payload,
149
+ headers={"Authorization": f"Bearer {token}"},
150
+ timeout=10,
151
+ )
152
+ return resp.is_success
153
+ except Exception:
154
+ return False
155
+
156
+
157
+ # ── Sync ──────────────────────────────────────────────────────────────
158
+
159
+
160
+ def load_sync_state() -> dict:
161
+ if not SYNC_STATE_FILE.exists():
162
+ return {}
163
+ return json.loads(SYNC_STATE_FILE.read_text())
164
+
165
+
166
+ def save_sync_state(state: dict) -> None:
167
+ SYNC_STATE_FILE.parent.mkdir(exist_ok=True)
168
+ SYNC_STATE_FILE.write_text(json.dumps(state, indent=2))
169
+
170
+
171
+ def _sanitize_for_push(memory: dict) -> dict:
172
+ """Convert a local memory dict to the shape the cloud API accepts."""
173
+ payload = {k: v for k, v in memory.items() if k in _PUSH_FIELDS}
174
+ if "id" in memory:
175
+ payload["localId"] = memory["id"]
176
+ # Local scope uses 'global' or 'project:xyz'; API uses 'local'|'global'.
177
+ payload["scope"] = "global"
178
+ if memory.get("last_confirmed"):
179
+ payload["lastConfirmed"] = memory["last_confirmed"]
180
+ return payload
181
+
182
+
183
+ def sync_push(memories: list[dict], config=None) -> dict:
184
+ token = get_token(config)
185
+ if not token:
186
+ raise RuntimeError("Not logged in. Run `deja login`.")
187
+ endpoint = _get_endpoint(config)
188
+ sanitized = [_sanitize_for_push(m) for m in memories]
189
+ resp = httpx.post(
190
+ f"{endpoint}/v1/sync/push",
191
+ json={"memories": sanitized},
192
+ headers={"Authorization": f"Bearer {token}"},
193
+ timeout=60,
194
+ )
195
+ if not resp.is_success:
196
+ raise RuntimeError(f"sync push failed ({resp.status_code}): {resp.text}")
197
+ return resp.json()
198
+
199
+
200
+ def sync_pull(since: Optional[str] = None, config=None) -> dict:
201
+ token = get_token(config)
202
+ if not token:
203
+ raise RuntimeError("Not logged in. Run `deja login`.")
204
+ endpoint = _get_endpoint(config)
205
+ params = f"?since={since}" if since else ""
206
+ resp = httpx.get(
207
+ f"{endpoint}/v1/sync/pull{params}",
208
+ headers={"Authorization": f"Bearer {token}"},
209
+ timeout=60,
210
+ )
211
+ resp.raise_for_status()
212
+ return resp.json()
@@ -97,12 +97,20 @@ class EmbeddingConfig(BaseModel):
97
97
  base_url: str = "http://localhost:11434"
98
98
 
99
99
 
100
+ class CloudConfig(BaseModel):
101
+ enabled: bool = False
102
+ endpoint: str = "https://api.deja.sh"
103
+ web_url: str = "https://www.deja.sh"
104
+ sync_on_save: bool = False
105
+
106
+
100
107
  class Config(BaseModel):
101
108
  llm: LLMConfig = LLMConfig()
102
109
  store: StoreConfig = StoreConfig()
103
110
  reflection: ReflectionConfig = ReflectionConfig()
104
111
  watchers: WatchersConfig = WatchersConfig()
105
112
  embedding: EmbeddingConfig = EmbeddingConfig()
113
+ cloud: CloudConfig = CloudConfig()
106
114
 
107
115
 
108
116
  def load_config(path: Optional[Path] = None) -> Config:
@@ -113,10 +121,6 @@ def load_config(path: Optional[Path] = None) -> Config:
113
121
  candidates.append(Path(path).expanduser())
114
122
  candidates.append(Path("~/.deja/config.yaml").expanduser())
115
123
 
116
- # Bundled default
117
- default_path = Path(__file__).parent.parent / "config" / "default.yaml"
118
- candidates.append(default_path)
119
-
120
124
  for candidate in candidates:
121
125
  if candidate.exists():
122
126
  with open(candidate) as f:
@@ -121,8 +121,7 @@ class ReflectionEngine:
121
121
  Two reflection modes
122
122
  --------------------
123
123
  LLM mode — uses the configured ``reflection`` LLM (Ollama / Anthropic).
124
- Triggered automatically by token-count thresholds or manually
125
- via ``deja reflect``.
124
+ Invoked manually via ``deja reflect`` or on a cron schedule.
126
125
 
127
126
  Agent mode — no extra LLM call. The active coding agent (Claude Code,
128
127
  Codex, Gemini CLI) reads the output of ``agent_mode_prompt()``
@@ -341,24 +340,3 @@ class ReflectionEngine:
341
340
  results["archive"] = await self.run_archive()
342
341
  return results
343
342
 
344
- async def check_and_trigger(self, project: Optional[str] = None) -> None:
345
- """Check token thresholds and auto-trigger observer/reflector if exceeded."""
346
- if not self.adapter:
347
- return
348
-
349
- meta = await self.store.get_reflection_meta(project)
350
- last_observer_at = meta.get("last_observer_at") if meta else None
351
-
352
- memories = await self.store.list_for_reflection(project, since=last_observer_at)
353
- token_count = sum(len(m["content"].split()) * 2 for m in memories)
354
-
355
- if token_count >= self.config.observer_trigger_tokens:
356
- n = await self.run_observer(project)
357
- print(f"[deja] Auto-observer triggered: {n} observations created.", file=sys.stderr)
358
-
359
- observations = await self.store.list_observations(project)
360
- obs_tokens = sum(len(o["content"].split()) * 2 for o in observations)
361
-
362
- if obs_tokens >= self.config.reflector_trigger_tokens:
363
- n = await self.run_reflector(project)
364
- print(f"[deja] Auto-reflector triggered: {n} observations reduced.", file=sys.stderr)
@@ -5,7 +5,7 @@ import struct
5
5
  import sys
6
6
  from datetime import datetime, timezone, timedelta
7
7
  from pathlib import Path
8
- from typing import Any, Optional
8
+ from typing import Any, NamedTuple, Optional
9
9
 
10
10
  import aiosqlite
11
11
  from ulid import ULID
@@ -138,6 +138,14 @@ def _parse_dt(dt_str: str) -> datetime:
138
138
 
139
139
  # ── store ──────────────────────────────────────────────────────────────────────
140
140
 
141
+
142
+ class SaveResult(NamedTuple):
143
+ """Return type for MemoryStore.save()."""
144
+ id: str
145
+ is_new: bool
146
+ cloud_data: dict
147
+
148
+
141
149
  _GLOBAL_PROJECT_KEY = "__global__"
142
150
 
143
151
 
@@ -302,11 +310,16 @@ class MemoryStore:
302
310
  pass # column already exists
303
311
  await db.commit()
304
312
 
305
- async def save(self, memory: dict, embedding: Optional[bytes] = None) -> str:
313
+ async def save(self, memory: dict, embedding: Optional[bytes] = None) -> SaveResult:
306
314
  """Save a memory, deduplicating if an existing memory is >80% similar.
307
315
 
308
316
  embedding: pre-computed embedding bytes (from EmbeddingAdapter.to_bytes()).
309
317
  Pass None if no embedding provider is configured.
318
+
319
+ Returns a SaveResult with:
320
+ .id — the memory ID (new or existing dedup match)
321
+ .is_new — True if a new record was inserted, False if deduplicated
322
+ .cloud_data — full current state of the saved/updated memory (no embedding blob)
310
323
  """
311
324
  db = await self._get_db()
312
325
  content = memory["content"]
@@ -346,7 +359,19 @@ class MemoryStore:
346
359
  (new_confidence, new_reuse, now, now, merged_trigger, candidate["id"]),
347
360
  )
348
361
  await db.commit()
349
- return candidate["id"]
362
+ cloud_data = {
363
+ "id": candidate["id"],
364
+ "content": content,
365
+ "type": mem_type,
366
+ "category": candidate.get("category", "agent"),
367
+ "scope": scope,
368
+ "project": project,
369
+ "confidence": new_confidence,
370
+ "trigger": merged_trigger,
371
+ "last_confirmed": now,
372
+ "updated_at": now,
373
+ }
374
+ return SaveResult(candidate["id"], False, cloud_data)
350
375
 
351
376
  # Insert new memory
352
377
  now = _now_iso()
@@ -379,7 +404,33 @@ class MemoryStore:
379
404
  ),
380
405
  )
381
406
  await db.commit()
382
- return mem_id
407
+ cloud_data = {
408
+ "id": mem_id,
409
+ "content": content,
410
+ "type": mem_type,
411
+ "category": memory.get("category", "agent"),
412
+ "scope": scope,
413
+ "project": project,
414
+ "confidence": memory.get("confidence", 1.0),
415
+ "trigger": memory.get("trigger"),
416
+ "created_at": now,
417
+ "updated_at": now,
418
+ "last_confirmed": now,
419
+ }
420
+ return SaveResult(mem_id, True, cloud_data)
421
+
422
+ async def confirm_memories(self, ids: list[str]) -> None:
423
+ """Stamp last_confirmed = now on a list of memory IDs. Called after load."""
424
+ if not ids:
425
+ return
426
+ db = await self._get_db()
427
+ now = _now_iso()
428
+ placeholders = ",".join("?" * len(ids))
429
+ await db.execute(
430
+ f"UPDATE memories SET last_confirmed = ?, updated_at = ? WHERE id IN ({placeholders})",
431
+ [now, now, *ids],
432
+ )
433
+ await db.commit()
383
434
 
384
435
  async def save_embedding(self, memory_id: str, embedding: bytes) -> None:
385
436
  """Store an embedding for an existing memory (used for backfill)."""
@@ -986,6 +1037,25 @@ class MemoryStore:
986
1037
  db = await self._get_db()
987
1038
  mem_id = memory["id"]
988
1039
 
1040
+ _KNOWN_COLUMNS = {
1041
+ "id", "type", "category", "content", "scope", "project", "source",
1042
+ "confidence", "reuse_count", "domain", "entity_graph", "trigger",
1043
+ "embedding", "created_at", "updated_at", "last_confirmed",
1044
+ "archived_at", "invalidated_at",
1045
+ }
1046
+ memory = {k: v for k, v in memory.items() if k in _KNOWN_COLUMNS}
1047
+
1048
+ # Fill NOT NULL defaults the cloud may omit
1049
+ now = _now_iso()
1050
+ memory.setdefault("created_at", now)
1051
+ memory.setdefault("updated_at", now)
1052
+ memory.setdefault("type", "semantic")
1053
+ memory.setdefault("category", "agent")
1054
+ memory.setdefault("content", "")
1055
+ memory.setdefault("scope", "global")
1056
+ memory.setdefault("confidence", 1.0)
1057
+ memory.setdefault("reuse_count", 0)
1058
+
989
1059
  existing = await self.get(mem_id)
990
1060
  if not existing:
991
1061
  # New record, just insert
@@ -0,0 +1,20 @@
1
+ from __future__ import annotations
2
+
3
+ import typer
4
+
5
+ from . import memory, session, transfer, maintenance, backfill, watch, cloud
6
+ from . import setup as setup_mod
7
+ from ._helpers import _embed_and_save, _format_load_result # re-exported for backwards compat
8
+
9
+ app = typer.Typer(name="deja", help="Deja — persistent coding memory CLI")
10
+
11
+ memory.register(app)
12
+ session.register(app)
13
+ transfer.register(app)
14
+ maintenance.register(app)
15
+ backfill.register(app)
16
+ watch.register(app)
17
+ setup_mod.register(app)
18
+ cloud.register(app)
19
+
20
+ __all__ = ["app", "_embed_and_save", "_format_load_result"]
@@ -0,0 +1,166 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import sys
5
+ from datetime import datetime, timezone
6
+ from pathlib import Path
7
+ from typing import Optional
8
+
9
+ from deja.config import load_config
10
+ from deja.core.store import MemoryStore
11
+ from deja.llm.embedding import EmbeddingAdapter
12
+
13
+
14
+ def _get_config():
15
+ return load_config()
16
+
17
+
18
+ def _get_store(config=None) -> MemoryStore:
19
+ if config is None:
20
+ config = _get_config()
21
+ return MemoryStore(config)
22
+
23
+
24
+ async def _init_store(store: MemoryStore) -> None:
25
+ await store.init_db()
26
+
27
+
28
+ async def _embed_and_save(
29
+ memories: list[dict],
30
+ store: MemoryStore,
31
+ embedding_adapter, # Optional[EmbeddingAdapter]
32
+ ) -> int:
33
+ """Embed each memory (if adapter available) and save. Returns count saved."""
34
+ saved = 0
35
+ for memory in memories:
36
+ emb_bytes = None
37
+ if embedding_adapter is not None:
38
+ try:
39
+ emb = await embedding_adapter.embed(memory["content"])
40
+ emb_bytes = EmbeddingAdapter.to_bytes(emb)
41
+ except Exception as e:
42
+ print(f"[deja] Embedding failed: {e}", file=sys.stderr)
43
+ await store.save(memory, emb_bytes)
44
+ saved += 1
45
+ return saved
46
+
47
+
48
+ def _format_memory_text(mem: dict) -> str:
49
+ scope = mem["scope"]
50
+ scope_label = "global" if scope == "global" else mem.get("project", scope)
51
+ domain = mem.get("domain")
52
+ domain_tag = f" [domain:{domain}]" if domain else ""
53
+ return (
54
+ f"[{mem['type']}]{domain_tag} [{scope_label}] {mem['content']} "
55
+ f"(confidence: {mem['confidence']:.1f})"
56
+ )
57
+
58
+
59
+ def _format_load_result(result: dict) -> str:
60
+ """Render a load_budgeted() result as compact, agent-readable text."""
61
+ memories = result["memories"]
62
+ total = result["total"]
63
+ overflow = result["overflow"]
64
+ project = result["project"]
65
+ overflow_hints = result.get("overflow_hints", [])
66
+
67
+ if not memories and total == 0:
68
+ return "No memories found."
69
+
70
+ header = f"=== deja: {len(memories)}/{total} memories"
71
+ if project != "global":
72
+ header += f" (project: {project})"
73
+ header += " ==="
74
+
75
+ lines = [header]
76
+
77
+ by_type: dict[str, list[dict]] = {}
78
+ for mem in memories:
79
+ t = mem.get("type", "pattern")
80
+ by_type.setdefault(t, []).append(mem)
81
+
82
+ type_order = ["preference", "gotcha", "decision", "pattern", "procedure", "progress"]
83
+ for mem_type in type_order:
84
+ mems = by_type.get(mem_type, [])
85
+ if not mems:
86
+ continue
87
+ lines.append("")
88
+ for mem in mems:
89
+ label = f"[{mem_type}]"
90
+ if mem.get("domain"):
91
+ label += f"[{mem['domain']}]"
92
+ if mem_type == "procedure" and mem.get("reuse_count", 0):
93
+ label += f"(reuse:{mem['reuse_count']})"
94
+ if mem.get("scope") != "global":
95
+ label += f"({mem.get('project', '')})"
96
+ lines.append(f"{label} {mem['content']}")
97
+
98
+ if overflow > 0:
99
+ lines.append("")
100
+ search_cmd = 'deja search "<topic>"'
101
+ if project != "global":
102
+ search_cmd += f" --project {project}"
103
+ hints_str = ", ".join(f"{h['type']} +{h['overflow']}" for h in overflow_hints)
104
+ lines.append(f"--- {overflow} more memories available. Run: {search_cmd} ---")
105
+ if hints_str:
106
+ lines.append(f"Overflow: {hints_str}")
107
+
108
+ return "\n".join(lines)
109
+
110
+
111
+ def _now_iso() -> str:
112
+ return datetime.now(timezone.utc).isoformat()
113
+
114
+
115
+ def _prepare_import_memory(raw: object, project: Optional[str]) -> tuple[Optional[dict], Optional[str]]:
116
+ """Validate and normalize one imported memory record."""
117
+ if not isinstance(raw, dict):
118
+ return None, "record is not a JSON object"
119
+
120
+ required_fields = ("id", "type", "content")
121
+ for field in required_fields:
122
+ value = raw.get(field)
123
+ if not isinstance(value, str) or not value.strip():
124
+ return None, f"missing required field: {field}"
125
+
126
+ scope = f"project:{project}" if project else raw.get("scope")
127
+ if not isinstance(scope, str) or not scope.strip():
128
+ return None, "missing required field: scope"
129
+
130
+ project_name = project if project else raw.get("project")
131
+ created_at = raw.get("created_at") or _now_iso()
132
+ updated_at = raw.get("updated_at") or created_at
133
+ last_confirmed = raw.get("last_confirmed") or updated_at
134
+
135
+ confidence = raw.get("confidence", 1.0)
136
+ try:
137
+ confidence = float(confidence)
138
+ except (TypeError, ValueError):
139
+ return None, "invalid confidence value"
140
+ confidence = max(0.0, min(1.0, confidence))
141
+
142
+ reuse_count = raw.get("reuse_count", 0)
143
+ try:
144
+ reuse_count = int(reuse_count)
145
+ except (TypeError, ValueError):
146
+ reuse_count = 0
147
+
148
+ normalized = {
149
+ "id": raw["id"],
150
+ "type": raw["type"],
151
+ "category": raw.get("category", "agent"),
152
+ "content": raw["content"],
153
+ "scope": scope,
154
+ "project": project_name,
155
+ "source": raw.get("source"),
156
+ "confidence": confidence,
157
+ "reuse_count": reuse_count,
158
+ "domain": raw.get("domain"),
159
+ "entity_graph": raw.get("entity_graph"),
160
+ "created_at": created_at,
161
+ "updated_at": updated_at,
162
+ "last_confirmed": last_confirmed,
163
+ "archived_at": raw.get("archived_at"),
164
+ "invalidated_at": raw.get("invalidated_at"),
165
+ }
166
+ return normalized, None