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.
- {deja_cli-0.1.0 → deja_cli-0.1.3}/PKG-INFO +1 -1
- {deja_cli-0.1.0 → deja_cli-0.1.3}/config/default.yaml +6 -0
- deja_cli-0.1.3/deja/cloud.py +212 -0
- {deja_cli-0.1.0 → deja_cli-0.1.3}/deja/config.py +8 -4
- {deja_cli-0.1.0 → deja_cli-0.1.3}/deja/core/reflection.py +1 -23
- {deja_cli-0.1.0 → deja_cli-0.1.3}/deja/core/store.py +74 -4
- deja_cli-0.1.3/deja/interfaces/cli/__init__.py +20 -0
- deja_cli-0.1.3/deja/interfaces/cli/_helpers.py +166 -0
- deja_cli-0.1.3/deja/interfaces/cli/backfill.py +322 -0
- deja_cli-0.1.3/deja/interfaces/cli/cloud.py +151 -0
- deja_cli-0.1.3/deja/interfaces/cli/maintenance.py +140 -0
- deja_cli-0.1.3/deja/interfaces/cli/memory.py +361 -0
- deja_cli-0.1.3/deja/interfaces/cli/session.py +219 -0
- deja_cli-0.1.3/deja/interfaces/cli/setup.py +494 -0
- deja_cli-0.1.3/deja/interfaces/cli/transfer.py +180 -0
- deja_cli-0.1.3/deja/interfaces/cli/watch.py +160 -0
- {deja_cli-0.1.0 → deja_cli-0.1.3}/deja/interfaces/mcp_server.py +30 -4
- deja_cli-0.1.3/hooks/deja-precompact.sh +20 -0
- {deja_cli-0.1.0 → deja_cli-0.1.3}/pyproject.toml +2 -1
- deja_cli-0.1.0/deja/core/scheduler.py +0 -65
- deja_cli-0.1.0/deja/interfaces/cli.py +0 -1967
- {deja_cli-0.1.0 → deja_cli-0.1.3}/.gitignore +0 -0
- {deja_cli-0.1.0 → deja_cli-0.1.3}/LICENSE +0 -0
- {deja_cli-0.1.0 → deja_cli-0.1.3}/README.pypi.md +0 -0
- {deja_cli-0.1.0 → deja_cli-0.1.3}/deja/__init__.py +0 -0
- {deja_cli-0.1.0 → deja_cli-0.1.3}/deja/core/__init__.py +0 -0
- {deja_cli-0.1.0 → deja_cli-0.1.3}/deja/core/extractor.py +0 -0
- {deja_cli-0.1.0 → deja_cli-0.1.3}/deja/ingest/__init__.py +0 -0
- {deja_cli-0.1.0 → deja_cli-0.1.3}/deja/ingest/watchers/__init__.py +0 -0
- {deja_cli-0.1.0 → deja_cli-0.1.3}/deja/ingest/watchers/base.py +0 -0
- {deja_cli-0.1.0 → deja_cli-0.1.3}/deja/ingest/watchers/claude_code.py +0 -0
- {deja_cli-0.1.0 → deja_cli-0.1.3}/deja/ingest/watchers/codex_cli.py +0 -0
- {deja_cli-0.1.0 → deja_cli-0.1.3}/deja/ingest/watchers/gemini_cli.py +0 -0
- {deja_cli-0.1.0 → deja_cli-0.1.3}/deja/interfaces/__init__.py +0 -0
- {deja_cli-0.1.0 → deja_cli-0.1.3}/deja/interfaces/web.py +0 -0
- {deja_cli-0.1.0 → deja_cli-0.1.3}/deja/interfaces/web_ui/index.html +0 -0
- {deja_cli-0.1.0 → deja_cli-0.1.3}/deja/llm/__init__.py +0 -0
- {deja_cli-0.1.0 → deja_cli-0.1.3}/deja/llm/base.py +0 -0
- {deja_cli-0.1.0 → deja_cli-0.1.3}/deja/llm/embedding.py +0 -0
- {deja_cli-0.1.0 → deja_cli-0.1.3}/deja/llm/factory.py +0 -0
- {deja_cli-0.1.0 → deja_cli-0.1.3}/deja/llm/providers/__init__.py +0 -0
- {deja_cli-0.1.0 → deja_cli-0.1.3}/deja/llm/providers/anthropic.py +0 -0
- {deja_cli-0.1.0 → deja_cli-0.1.3}/deja/llm/providers/ollama.py +0 -0
- {deja_cli-0.1.0 → deja_cli-0.1.3}/deja/main.py +0 -0
- {deja_cli-0.1.0 → deja_cli-0.1.3}/hooks/deja-post-fail.sh +0 -0
- {deja_cli-0.1.0 → deja_cli-0.1.3}/hooks/deja-recall.sh +0 -0
|
@@ -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
|
-
|
|
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) ->
|
|
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
|
-
|
|
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
|
-
|
|
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
|