trailmem 0.1.0__py3-none-any.whl
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.
- trailmem/__init__.py +3 -0
- trailmem/cli.py +613 -0
- trailmem/config.py +38 -0
- trailmem/dashboard.py +846 -0
- trailmem/embeddings.py +79 -0
- trailmem/integrate.py +201 -0
- trailmem/mcp_server.py +197 -0
- trailmem/models.py +197 -0
- trailmem/ops.py +166 -0
- trailmem/queries.py +196 -0
- trailmem/schema.py +243 -0
- trailmem/sessions.py +214 -0
- trailmem/store.py +229 -0
- trailmem-0.1.0.dist-info/METADATA +120 -0
- trailmem-0.1.0.dist-info/RECORD +18 -0
- trailmem-0.1.0.dist-info/WHEEL +4 -0
- trailmem-0.1.0.dist-info/entry_points.txt +3 -0
- trailmem-0.1.0.dist-info/licenses/LICENSE +21 -0
trailmem/__init__.py
ADDED
trailmem/cli.py
ADDED
|
@@ -0,0 +1,613 @@
|
|
|
1
|
+
"""trailmem CLI — full command surface per docs/cli.md.
|
|
2
|
+
|
|
3
|
+
Exit codes: 0 ok, 1 general error, 2 validation, 3 exact duplicate,
|
|
4
|
+
4 near-duplicate blocked.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import argparse
|
|
8
|
+
import json
|
|
9
|
+
import os
|
|
10
|
+
import sys
|
|
11
|
+
|
|
12
|
+
from . import __version__, dashboard, embeddings, integrate, models, ops, queries, sessions
|
|
13
|
+
from . import store as store_mod
|
|
14
|
+
from .config import CONFIG_PATH, TRAILMEM_HOME, db_path, load_config, save_config
|
|
15
|
+
from .schema import connect, has_vec, init_db
|
|
16
|
+
from .store import ValidationError
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _conn():
|
|
20
|
+
conn = connect()
|
|
21
|
+
init_db(conn)
|
|
22
|
+
return conn
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _ctx(agent=None):
|
|
26
|
+
agent = store_mod.resolve_agent(agent)
|
|
27
|
+
proj = store_mod.resolve_project(None)
|
|
28
|
+
sid = os.environ.get("CLAUDE_CODE_SESSION_ID") or os.environ.get("KIRO_SESSION_ID")
|
|
29
|
+
return agent, proj, sid
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
# ---- write ops ----
|
|
33
|
+
|
|
34
|
+
def cmd_store(a) -> int:
|
|
35
|
+
conn = _conn()
|
|
36
|
+
agent, proj, sid = _ctx(a.agent)
|
|
37
|
+
r = store_mod.store(
|
|
38
|
+
conn, a.content, a.title, a.type, agent_type=agent, work_type=a.work_type,
|
|
39
|
+
project=proj, session_id=sid, source_uri=a.source, modified_files=a.modified_files,
|
|
40
|
+
pinned=a.pin, link_to=a.link_to, edge_type=a.edge_type, force=a.force,
|
|
41
|
+
)
|
|
42
|
+
if r["outcome"] == "rejected_exact":
|
|
43
|
+
d = r["duplicate"]
|
|
44
|
+
print(f"Rejected: exact duplicate of #{d['id']} [{d['node_id']}] '{d['title']}'. "
|
|
45
|
+
f"Use trailmem edit {d['id']}.")
|
|
46
|
+
return 3
|
|
47
|
+
if r["outcome"] == "blocked_near_dup":
|
|
48
|
+
d = r["duplicate"]
|
|
49
|
+
print(f"Blocked: {d['similarity']:.0%} similar to #{d['id']} [{d['node_id']}] "
|
|
50
|
+
f"'{d['title']}'. Edit it or pass --force.")
|
|
51
|
+
return 4
|
|
52
|
+
print(f"Stored #{r['id']} [{r['node_id']}] '{a.title}'.")
|
|
53
|
+
if a.supersedes:
|
|
54
|
+
s = ops.supersede(conn, r["node_id"], a.supersedes, a.archive_reason)
|
|
55
|
+
print(f"Superseded #{s['old_id']} '{s['old_title']}' (archived).")
|
|
56
|
+
if r.get("linked"):
|
|
57
|
+
print(f"Linked to {r['linked']['target']} [{r['linked']['edge_type']}].")
|
|
58
|
+
for w in r.get("warnings", []):
|
|
59
|
+
print(f"⚠ {w}")
|
|
60
|
+
for c in r.get("related_candidates", []):
|
|
61
|
+
print(f"Related candidate: #{c['id']} [{c['node_id']}] ({c['similarity']})")
|
|
62
|
+
return 0
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def cmd_edit(a) -> int:
|
|
66
|
+
r = ops.edit(_conn(), a.ref, content=a.content, title=a.title, event_type=a.type,
|
|
67
|
+
pinned=a.pin, link_to=a.link_to, edge_type=a.edge_type,
|
|
68
|
+
status=a.status, archive_reason=a.reason)
|
|
69
|
+
print(f"Updated #{r['id']} [{r['node_id']}] '{r['title']}': "
|
|
70
|
+
f"{', '.join(r['changed']) or 'nothing'}.")
|
|
71
|
+
return 0
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def cmd_archive(a) -> int:
|
|
75
|
+
r = ops.edit(_conn(), a.ref, status="archived", archive_reason=a.reason,
|
|
76
|
+
link_to=a.link_to)
|
|
77
|
+
print(f"Archived #{r['id']} [{r['node_id']}] '{r['title']}'. Reason: '{a.reason}'.")
|
|
78
|
+
return 0
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def cmd_link(a) -> int:
|
|
82
|
+
r = ops.link_add(_conn(), a.source, a.target, a.type, a.reason or "")
|
|
83
|
+
if r["duplicate"]:
|
|
84
|
+
print(f"Edge already exists: #{r['source_id']} → #{r['target_id']} [{r['edge_type']}].")
|
|
85
|
+
else:
|
|
86
|
+
print(f"Linked #{r['source_id']} → #{r['target_id']} [{r['edge_type']}] (edge e{r['edge_id']}).")
|
|
87
|
+
return 0
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def cmd_unlink(a) -> int:
|
|
91
|
+
conn = _conn()
|
|
92
|
+
r = ops.link_remove(conn, a.edge_id)
|
|
93
|
+
print(f"Unlinked edge #{r['edge_id']} ({r['source']} → {r['target']} [{r['edge_type']}]).")
|
|
94
|
+
for n in r["orphaned"]:
|
|
95
|
+
m = queries.resolve_ref(conn, n)
|
|
96
|
+
print(f"⚠ #{m['id']} now has 0 edges (orphan). Consider linking.")
|
|
97
|
+
return 0
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _set_pin(ref, value) -> int:
|
|
101
|
+
r = ops.edit(_conn(), ref, pinned=value)
|
|
102
|
+
print(f"{'Pinned' if value else 'Unpinned'} #{r['id']} [{r['node_id']}] '{r['title']}'.")
|
|
103
|
+
return 0
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def cmd_delete(a) -> int:
|
|
107
|
+
conn = _conn()
|
|
108
|
+
m = queries.resolve_ref(conn, a.ref)
|
|
109
|
+
if not m:
|
|
110
|
+
raise ValidationError(f"ref '{a.ref}' not found")
|
|
111
|
+
if not a.confirm:
|
|
112
|
+
print("Refusing: hard delete needs --confirm. (Prefer trailmem archive — keeps the trail.)")
|
|
113
|
+
return 1
|
|
114
|
+
node_id = m["node_id"]
|
|
115
|
+
# Virtual tables do NOT cascade — all three explicitly. Edges cascade via FK.
|
|
116
|
+
conn.execute("DELETE FROM memories WHERE node_id = ?", (node_id,))
|
|
117
|
+
conn.execute("DELETE FROM memories_fts WHERE node_id = ?", (node_id,))
|
|
118
|
+
if has_vec(conn):
|
|
119
|
+
try:
|
|
120
|
+
conn.execute("DELETE FROM memories_vec WHERE node_id = ?", (node_id,))
|
|
121
|
+
except Exception:
|
|
122
|
+
pass
|
|
123
|
+
conn.commit()
|
|
124
|
+
print(f"Deleted #{m['id']} [{node_id}] '{m['title']}' permanently (edges cascaded).")
|
|
125
|
+
return 0
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
# ---- read ops ----
|
|
129
|
+
|
|
130
|
+
def cmd_show(a) -> int:
|
|
131
|
+
data = queries.show(_conn(), a.ref)
|
|
132
|
+
if not data:
|
|
133
|
+
raise ValidationError(f"ref '{a.ref}' not found")
|
|
134
|
+
print(queries.format_show(data))
|
|
135
|
+
return 0
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def cmd_query(a) -> int:
|
|
139
|
+
_, proj, _ = _ctx()
|
|
140
|
+
results = queries.query(_conn(), a.text, type_filter=a.type, agent_filter=a.agent,
|
|
141
|
+
project=proj, limit=a.limit)
|
|
142
|
+
if a.format == "json":
|
|
143
|
+
print(json.dumps(results, indent=2, default=str))
|
|
144
|
+
else:
|
|
145
|
+
print(queries.format_query_results(results, a.text))
|
|
146
|
+
return 0
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def cmd_welcome(a) -> int:
|
|
150
|
+
agent, proj, sid = _ctx(a.agent)
|
|
151
|
+
sid = sid or f"cli-{os.getppid()}"
|
|
152
|
+
print(sessions.welcome(_conn(), sid, agent, proj, force=a.force))
|
|
153
|
+
return 0
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def cmd_similar(a) -> int:
|
|
157
|
+
import hashlib
|
|
158
|
+
conn = _conn()
|
|
159
|
+
_, proj, _ = _ctx()
|
|
160
|
+
h = hashlib.sha256(a.content.encode()).hexdigest()
|
|
161
|
+
dup = conn.execute(
|
|
162
|
+
"SELECT id, node_id, title FROM memories WHERE content_hash = ? AND project IS ?",
|
|
163
|
+
(h, proj)).fetchone()
|
|
164
|
+
if dup:
|
|
165
|
+
print(f"exact: #{dup['id']} [{dup['node_id']}] '{dup['title']}'")
|
|
166
|
+
return 0
|
|
167
|
+
vec = embeddings.embed(a.content)
|
|
168
|
+
if vec is None:
|
|
169
|
+
print("(embeddings unavailable — only exact-hash checked; no exact match)")
|
|
170
|
+
return 0
|
|
171
|
+
cfg = load_config()["embedding"]
|
|
172
|
+
for n in store_mod._similar(conn, vec):
|
|
173
|
+
band = ("0.92+" if n["similarity"] > cfg["dedup_block"]
|
|
174
|
+
else "0.85+" if n["similarity"] >= cfg["dedup_warn"] else "low")
|
|
175
|
+
print(f"{band}: #{n['id']} [{n['node_id']}] '{n['title']}' ({n['similarity']:.2f})")
|
|
176
|
+
return 0
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def cmd_list(a) -> int:
|
|
180
|
+
conn = _conn()
|
|
181
|
+
_, proj, _ = _ctx()
|
|
182
|
+
where, params, order, limit = ["1=1"], [], "created_at DESC", None
|
|
183
|
+
if a.archived:
|
|
184
|
+
where.append("status != 'active'")
|
|
185
|
+
else:
|
|
186
|
+
where.append("status = 'active'")
|
|
187
|
+
if a.pinned:
|
|
188
|
+
where.append("(pinned = 1 OR event_type = 'constraint')")
|
|
189
|
+
if a.tasks:
|
|
190
|
+
where.append("event_type = 'task'")
|
|
191
|
+
if a.by_agent:
|
|
192
|
+
where.append("agent_type = ?"); params.append(a.by_agent)
|
|
193
|
+
if getattr(a, "global"):
|
|
194
|
+
where.append("project IS NULL")
|
|
195
|
+
elif a.project:
|
|
196
|
+
where.append("project = ?"); params.append(a.project)
|
|
197
|
+
else:
|
|
198
|
+
where.append("(project = ? OR project IS NULL)"); params.append(proj)
|
|
199
|
+
if a.recent:
|
|
200
|
+
limit = 10
|
|
201
|
+
rows = conn.execute(
|
|
202
|
+
f"SELECT * FROM memories WHERE {' AND '.join(where)} ORDER BY {order}"
|
|
203
|
+
+ (f" LIMIT {limit}" if limit else ""), params).fetchall()
|
|
204
|
+
if a.orphans:
|
|
205
|
+
rows = [m for m in rows if queries.edge_count(conn, m["node_id"]) == 0]
|
|
206
|
+
|
|
207
|
+
if a.format == "json":
|
|
208
|
+
print(json.dumps([dict(m) for m in rows], indent=2, default=str))
|
|
209
|
+
return 0
|
|
210
|
+
day = None
|
|
211
|
+
for m in rows:
|
|
212
|
+
if a.timeline and m["created_at"][:10] != day:
|
|
213
|
+
day = m["created_at"][:10]
|
|
214
|
+
print(f"\n== {day} ==")
|
|
215
|
+
n = queries.edge_count(conn, m["node_id"])
|
|
216
|
+
tag = "" if m["status"] == "active" else f" [{m['status']}]"
|
|
217
|
+
print(f"#{m['id']} [{m['node_id']}] [{m['event_type']}] [{m['agent_type']}]"
|
|
218
|
+
f"{tag} [↔{n}] {m['title']}")
|
|
219
|
+
if not rows:
|
|
220
|
+
print("(no memories)")
|
|
221
|
+
return 0
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def cmd_stats(a) -> int:
|
|
225
|
+
conn = _conn()
|
|
226
|
+
_, proj, _ = _ctx()
|
|
227
|
+
print(sessions._stats_line(conn, proj))
|
|
228
|
+
print(f"DB: {db_path()} ({db_path().stat().st_size // 1024} KB)")
|
|
229
|
+
for row in conn.execute(
|
|
230
|
+
"SELECT event_type, COUNT(*) c FROM memories WHERE status='active' "
|
|
231
|
+
"GROUP BY event_type ORDER BY c DESC"):
|
|
232
|
+
print(f" {row['event_type']}: {row['c']}")
|
|
233
|
+
return 0
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def cmd_dashboard(a) -> int:
|
|
237
|
+
"""Start the first-party local dashboard; it is never a public listener."""
|
|
238
|
+
if not 1 <= a.port <= 65535:
|
|
239
|
+
raise ValidationError("dashboard port must be between 1 and 65535")
|
|
240
|
+
project = store_mod.resolve_project(a.project)
|
|
241
|
+
agent = store_mod.resolve_agent(a.agent or "user")
|
|
242
|
+
return dashboard.serve(port=a.port, project=project, default_agent=agent)
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
# ---- admin ----
|
|
246
|
+
|
|
247
|
+
def cmd_maintain(a) -> int:
|
|
248
|
+
conn = _conn()
|
|
249
|
+
old = conn.execute(
|
|
250
|
+
"SELECT COUNT(*) FROM sessions WHERE last_seen_at < datetime('now', '-90 days')"
|
|
251
|
+
).fetchone()[0]
|
|
252
|
+
orphans = conn.execute(
|
|
253
|
+
"SELECT COUNT(*) FROM memories m WHERE m.status='active' AND NOT EXISTS "
|
|
254
|
+
"(SELECT 1 FROM edges e WHERE e.source_node_id=m.node_id OR e.target_node_id=m.node_id)"
|
|
255
|
+
).fetchone()[0]
|
|
256
|
+
print(f"sessions >90 days old: {old}")
|
|
257
|
+
print(f"orphan memories (report only, never auto-archived): {orphans}")
|
|
258
|
+
if not a.apply:
|
|
259
|
+
print("(dry-run — pass --apply to purge old sessions)")
|
|
260
|
+
return 0
|
|
261
|
+
if old and input(f"Purge {old} old sessions? [y/N] ").lower() != "y":
|
|
262
|
+
print("aborted")
|
|
263
|
+
return 0
|
|
264
|
+
conn.execute("DELETE FROM sessions WHERE last_seen_at < datetime('now', '-90 days')")
|
|
265
|
+
conn.commit()
|
|
266
|
+
print(f"purged {old} sessions")
|
|
267
|
+
return 0
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def cmd_export(a) -> int:
|
|
271
|
+
conn = _conn()
|
|
272
|
+
data = {
|
|
273
|
+
"trailmem_export": 1,
|
|
274
|
+
"memories": [dict(r) for r in conn.execute("SELECT * FROM memories")],
|
|
275
|
+
"edges": [dict(r) for r in conn.execute("SELECT * FROM edges")],
|
|
276
|
+
}
|
|
277
|
+
out = json.dumps(data, indent=2, default=str)
|
|
278
|
+
if a.output:
|
|
279
|
+
with open(a.output, "w") as f:
|
|
280
|
+
f.write(out)
|
|
281
|
+
print(f"exported {len(data['memories'])} memories + {len(data['edges'])} edges → {a.output}")
|
|
282
|
+
else:
|
|
283
|
+
print(out)
|
|
284
|
+
return 0
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
def cmd_import(a) -> int:
|
|
288
|
+
conn = _conn()
|
|
289
|
+
with open(a.file) as f:
|
|
290
|
+
data = json.load(f)
|
|
291
|
+
if "trailmem_export" not in data:
|
|
292
|
+
raise ValidationError(f"{a.file} is not a trailmem export")
|
|
293
|
+
if a.replace:
|
|
294
|
+
if input("REPLACE wipes the entire DB. Type 'replace' to confirm: ") != "replace":
|
|
295
|
+
print("aborted")
|
|
296
|
+
return 0
|
|
297
|
+
if input("Really? Type 'yes': ") != "yes":
|
|
298
|
+
print("aborted")
|
|
299
|
+
return 0
|
|
300
|
+
for t in ("edges", "memories", "memories_fts"):
|
|
301
|
+
conn.execute(f"DELETE FROM {t}")
|
|
302
|
+
if has_vec(conn):
|
|
303
|
+
try:
|
|
304
|
+
conn.execute("DELETE FROM memories_vec")
|
|
305
|
+
except Exception:
|
|
306
|
+
pass
|
|
307
|
+
added = 0
|
|
308
|
+
for m in data["memories"]:
|
|
309
|
+
if conn.execute("SELECT 1 FROM memories WHERE node_id=?", (m["node_id"],)).fetchone():
|
|
310
|
+
continue
|
|
311
|
+
cols = [k for k in m if k != "id"]
|
|
312
|
+
conn.execute(
|
|
313
|
+
f"INSERT INTO memories ({','.join(cols)}) VALUES ({','.join('?' * len(cols))})",
|
|
314
|
+
[m[c] for c in cols])
|
|
315
|
+
conn.execute("INSERT INTO memories_fts (node_id, title, content) VALUES (?,?,?)",
|
|
316
|
+
(m["node_id"], m["title"], m["content"]))
|
|
317
|
+
added += 1
|
|
318
|
+
for e in data["edges"]:
|
|
319
|
+
conn.execute(
|
|
320
|
+
"INSERT OR IGNORE INTO edges (source_node_id, target_node_id, edge_type, "
|
|
321
|
+
"weight, metadata, created_at) VALUES (?,?,?,?,?,?)",
|
|
322
|
+
(e["source_node_id"], e["target_node_id"], e["edge_type"],
|
|
323
|
+
e.get("weight", 1.0), e.get("metadata"), e["created_at"]))
|
|
324
|
+
conn.commit()
|
|
325
|
+
print(f"imported {added} new memories. Run `trailmem reindex` to embed them.")
|
|
326
|
+
return 0
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
def cmd_setup(a) -> int:
|
|
330
|
+
TRAILMEM_HOME.mkdir(parents=True, exist_ok=True)
|
|
331
|
+
if not CONFIG_PATH.exists():
|
|
332
|
+
save_config(load_config())
|
|
333
|
+
print(f"wrote {CONFIG_PATH}")
|
|
334
|
+
conn = _conn()
|
|
335
|
+
conn.close()
|
|
336
|
+
print(f"database ready: {db_path()}")
|
|
337
|
+
cfg = load_config()["embedding"]
|
|
338
|
+
if cfg["enabled"] and not models.installed(cfg["model"]):
|
|
339
|
+
print(f"downloading default embedding model '{cfg['model']}' ...")
|
|
340
|
+
if models.install(cfg["model"]) != 0:
|
|
341
|
+
print("⚠ model download failed — trailmem works in FTS-only mode until "
|
|
342
|
+
f"`trailmem model install {cfg['model']}` succeeds.")
|
|
343
|
+
print("MCP registration: run `trailmem integrate` (auto-detect, asks first), or manually →")
|
|
344
|
+
print(' claude mcp add trailmem -- trailmem-mcp')
|
|
345
|
+
return 0
|
|
346
|
+
|
|
347
|
+
|
|
348
|
+
def cmd_integrate(a) -> int:
|
|
349
|
+
return integrate.run()
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
def cmd_doctor(a) -> int:
|
|
353
|
+
ok = True
|
|
354
|
+
cfg = load_config()
|
|
355
|
+
print(f"home: {TRAILMEM_HOME} {'✓' if TRAILMEM_HOME.exists() else '✗ (run trailmem setup)'}")
|
|
356
|
+
print(f"config: {CONFIG_PATH} {'✓' if CONFIG_PATH.exists() else '✗'}")
|
|
357
|
+
if not db_path().exists():
|
|
358
|
+
print(f"db: {db_path()} ✗ (run trailmem setup)")
|
|
359
|
+
return 1
|
|
360
|
+
conn = connect()
|
|
361
|
+
tables = {r[0] for r in conn.execute("SELECT name FROM sqlite_master WHERE type='table'")}
|
|
362
|
+
for t in ("memories", "edges", "sessions", "memories_fts"):
|
|
363
|
+
present = t in tables
|
|
364
|
+
ok &= present
|
|
365
|
+
print(f"table: {t} {'✓' if present else '✗'}")
|
|
366
|
+
emb = cfg["embedding"]
|
|
367
|
+
if emb["enabled"]:
|
|
368
|
+
vec = has_vec(conn)
|
|
369
|
+
print(f"vec: sqlite-vec {'✓' if vec else '✗ DEGRADED — FTS-only, near-dup detection OFF'}")
|
|
370
|
+
got = models.installed(emb["model"])
|
|
371
|
+
print(f"model: {emb['model']} ({emb['dimensions']}d) "
|
|
372
|
+
f"{'✓' if got else '✗ not installed — run: trailmem model install ' + emb['model']}")
|
|
373
|
+
ok &= vec and got
|
|
374
|
+
else:
|
|
375
|
+
print("vec: disabled by config — FTS-only mode (exact-hash dedup only)")
|
|
376
|
+
conn.close()
|
|
377
|
+
return 0 if ok else 1
|
|
378
|
+
|
|
379
|
+
|
|
380
|
+
def cmd_model(a) -> int:
|
|
381
|
+
if a.model_cmd == "list":
|
|
382
|
+
cfg = load_config()["embedding"]
|
|
383
|
+
for name, spec in models.REGISTRY.items():
|
|
384
|
+
mark = " (active)" if cfg["enabled"] and cfg["model"] == name else ""
|
|
385
|
+
state = "installed" if models.installed(name) else "available"
|
|
386
|
+
print(f"{name}: {state}{mark} — {spec['note']}")
|
|
387
|
+
if not cfg["enabled"]:
|
|
388
|
+
print("(embeddings disabled — FTS-only mode)")
|
|
389
|
+
return 0
|
|
390
|
+
if a.model_cmd == "install":
|
|
391
|
+
# Default name differs by mode: registry download → default model;
|
|
392
|
+
# custom --path → 'custom', never a registry name (overwrite guard).
|
|
393
|
+
return models.install(a.name or ("custom" if a.path else "bge-small"), a.path)
|
|
394
|
+
if a.model_cmd == "use":
|
|
395
|
+
return models.use(a.name)
|
|
396
|
+
if a.model_cmd == "disable":
|
|
397
|
+
return models.disable()
|
|
398
|
+
return 1
|
|
399
|
+
|
|
400
|
+
|
|
401
|
+
def cmd_reindex(a) -> int:
|
|
402
|
+
return models.reindex(_conn())
|
|
403
|
+
|
|
404
|
+
|
|
405
|
+
# ---- hooks (never fail the host session) ----
|
|
406
|
+
|
|
407
|
+
def cmd_hook(a) -> int:
|
|
408
|
+
import traceback
|
|
409
|
+
try:
|
|
410
|
+
agent, proj, sid = _ctx(a.agent)
|
|
411
|
+
except ValidationError:
|
|
412
|
+
agent, proj, sid = None, os.getcwd(), None
|
|
413
|
+
try:
|
|
414
|
+
if a.event == "session-start":
|
|
415
|
+
conn = _conn()
|
|
416
|
+
if agent and sid:
|
|
417
|
+
print(sessions.welcome(conn, sid, agent, proj))
|
|
418
|
+
elif agent: # session-less mode: welcome without boundary tracking
|
|
419
|
+
print(sessions.welcome(conn, f"adhoc-{os.getppid()}", agent, proj))
|
|
420
|
+
elif a.event == "session-stop":
|
|
421
|
+
if sid:
|
|
422
|
+
conn = _conn()
|
|
423
|
+
conn.execute("UPDATE sessions SET last_seen_at = ? WHERE session_id = ?",
|
|
424
|
+
(store_mod.now(), sid))
|
|
425
|
+
conn.commit()
|
|
426
|
+
except Exception:
|
|
427
|
+
try:
|
|
428
|
+
log = TRAILMEM_HOME / "hooks.log"
|
|
429
|
+
TRAILMEM_HOME.mkdir(parents=True, exist_ok=True)
|
|
430
|
+
with open(log, "a") as f:
|
|
431
|
+
f.write(f"--- {store_mod.now()} {a.event}\n{traceback.format_exc()}\n")
|
|
432
|
+
except Exception:
|
|
433
|
+
pass
|
|
434
|
+
print(f"trailmem hook {a.event} failed (see ~/.trailmem/hooks.log)", file=sys.stderr)
|
|
435
|
+
return 0 # ALWAYS — a memory failure must never block the agent
|
|
436
|
+
|
|
437
|
+
|
|
438
|
+
# ---- parser ----
|
|
439
|
+
|
|
440
|
+
def main(argv=None) -> int:
|
|
441
|
+
p = argparse.ArgumentParser(
|
|
442
|
+
prog="trailmem",
|
|
443
|
+
description="Graph-linked persistent memory for AI coding agents",
|
|
444
|
+
epilog=(
|
|
445
|
+
"examples:\n"
|
|
446
|
+
" trailmem setup first-time setup (config, DB, model)\n"
|
|
447
|
+
" trailmem doctor health check\n"
|
|
448
|
+
" trailmem store --title \"Note\" --type lesson --agent user \"content here\"\n"
|
|
449
|
+
" trailmem query \"what did I learn about X\"\n"
|
|
450
|
+
" trailmem list list stored memories\n"
|
|
451
|
+
" trailmem <command> --help help for any command\n"
|
|
452
|
+
),
|
|
453
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
454
|
+
)
|
|
455
|
+
p.add_argument("--version", action="version", version=f"trailmem {__version__}")
|
|
456
|
+
sub = p.add_subparsers(dest="command", required=True)
|
|
457
|
+
|
|
458
|
+
s = sub.add_parser("store", help="Store a new memory")
|
|
459
|
+
s.add_argument("content")
|
|
460
|
+
s.add_argument("--title", required=True)
|
|
461
|
+
s.add_argument("--type", required=True)
|
|
462
|
+
s.add_argument("--agent")
|
|
463
|
+
s.add_argument("--work-type")
|
|
464
|
+
s.add_argument("--source")
|
|
465
|
+
s.add_argument("--modified-files")
|
|
466
|
+
s.add_argument("--pin", action="store_true")
|
|
467
|
+
s.add_argument("--link-to")
|
|
468
|
+
s.add_argument("--edge-type", default="related")
|
|
469
|
+
s.add_argument("--supersedes")
|
|
470
|
+
s.add_argument("--archive-reason")
|
|
471
|
+
s.add_argument("--force", action="store_true")
|
|
472
|
+
s.set_defaults(func=cmd_store)
|
|
473
|
+
|
|
474
|
+
s = sub.add_parser("edit", help="Edit a memory")
|
|
475
|
+
s.add_argument("ref")
|
|
476
|
+
s.add_argument("--content")
|
|
477
|
+
s.add_argument("--title")
|
|
478
|
+
s.add_argument("--type")
|
|
479
|
+
s.add_argument("--pin", action=argparse.BooleanOptionalAction)
|
|
480
|
+
s.add_argument("--status", choices=["archived", "superseded"])
|
|
481
|
+
s.add_argument("--reason")
|
|
482
|
+
s.add_argument("--link-to")
|
|
483
|
+
s.add_argument("--edge-type", default="related")
|
|
484
|
+
s.set_defaults(func=cmd_edit)
|
|
485
|
+
|
|
486
|
+
s = sub.add_parser("archive", help="Archive a memory (preserves trail)")
|
|
487
|
+
s.add_argument("ref")
|
|
488
|
+
s.add_argument("--reason", required=True)
|
|
489
|
+
s.add_argument("--link-to")
|
|
490
|
+
s.set_defaults(func=cmd_archive)
|
|
491
|
+
|
|
492
|
+
s = sub.add_parser("link", help="Link two memories")
|
|
493
|
+
s.add_argument("source")
|
|
494
|
+
s.add_argument("target")
|
|
495
|
+
s.add_argument("--type", default="related")
|
|
496
|
+
s.add_argument("--reason")
|
|
497
|
+
s.set_defaults(func=cmd_link)
|
|
498
|
+
|
|
499
|
+
s = sub.add_parser("unlink", help="Remove an edge")
|
|
500
|
+
s.add_argument("edge_id", type=int)
|
|
501
|
+
s.set_defaults(func=cmd_unlink)
|
|
502
|
+
|
|
503
|
+
s = sub.add_parser("pin", help="Pin a memory")
|
|
504
|
+
s.add_argument("ref")
|
|
505
|
+
s.set_defaults(func=lambda a: _set_pin(a.ref, True))
|
|
506
|
+
s = sub.add_parser("unpin", help="Unpin a memory")
|
|
507
|
+
s.add_argument("ref")
|
|
508
|
+
s.set_defaults(func=lambda a: _set_pin(a.ref, False))
|
|
509
|
+
|
|
510
|
+
s = sub.add_parser("show", help="Show one memory in full (+ edges)")
|
|
511
|
+
s.add_argument("ref")
|
|
512
|
+
s.set_defaults(func=cmd_show)
|
|
513
|
+
|
|
514
|
+
s = sub.add_parser("query", help="Search memories")
|
|
515
|
+
s.add_argument("text")
|
|
516
|
+
s.add_argument("--type")
|
|
517
|
+
s.add_argument("--agent")
|
|
518
|
+
s.add_argument("--limit", type=int, default=5)
|
|
519
|
+
s.add_argument("--format", choices=["text", "json"], default="text")
|
|
520
|
+
s.set_defaults(func=cmd_query)
|
|
521
|
+
|
|
522
|
+
s = sub.add_parser("welcome", help="Session briefing")
|
|
523
|
+
s.add_argument("--agent")
|
|
524
|
+
s.add_argument("--force", action="store_true")
|
|
525
|
+
s.set_defaults(func=cmd_welcome)
|
|
526
|
+
|
|
527
|
+
s = sub.add_parser("similar", help="Check near-duplicates before storing")
|
|
528
|
+
s.add_argument("content")
|
|
529
|
+
s.set_defaults(func=cmd_similar)
|
|
530
|
+
|
|
531
|
+
s = sub.add_parser("list", help="List memories (filterable)")
|
|
532
|
+
for flag in ("recent", "orphans", "pinned", "tasks", "timeline", "archived"):
|
|
533
|
+
s.add_argument(f"--{flag}", action="store_true")
|
|
534
|
+
s.add_argument("--by-agent")
|
|
535
|
+
s.add_argument("--project")
|
|
536
|
+
s.add_argument("--global", action="store_true")
|
|
537
|
+
s.add_argument("--format", choices=["text", "json"], default="text")
|
|
538
|
+
s.set_defaults(func=cmd_list)
|
|
539
|
+
|
|
540
|
+
s = sub.add_parser("stats", help="Statistics")
|
|
541
|
+
s.set_defaults(func=cmd_stats)
|
|
542
|
+
|
|
543
|
+
s = sub.add_parser("dashboard", help="Start the loopback-only local dashboard")
|
|
544
|
+
s.add_argument("--port", type=int, default=3800)
|
|
545
|
+
s.add_argument("--project", help="Project scope (use 'global' for global memories only)")
|
|
546
|
+
s.add_argument("--agent", help="Default attribution for dashboard-created memories")
|
|
547
|
+
s.set_defaults(func=cmd_dashboard)
|
|
548
|
+
|
|
549
|
+
s = sub.add_parser("maintain", help="Maintenance (dry-run by default)")
|
|
550
|
+
s.add_argument("--apply", action="store_true")
|
|
551
|
+
s.set_defaults(func=cmd_maintain)
|
|
552
|
+
|
|
553
|
+
s = sub.add_parser("export", help="Full DB dump to JSON")
|
|
554
|
+
s.add_argument("output", nargs="?")
|
|
555
|
+
s.add_argument("--format", choices=["json"], default="json")
|
|
556
|
+
s.set_defaults(func=cmd_export)
|
|
557
|
+
|
|
558
|
+
s = sub.add_parser("import", help="Import from a trailmem export")
|
|
559
|
+
s.add_argument("file")
|
|
560
|
+
g = s.add_mutually_exclusive_group(required=True)
|
|
561
|
+
g.add_argument("--merge", action="store_true")
|
|
562
|
+
g.add_argument("--replace", action="store_true")
|
|
563
|
+
s.set_defaults(func=cmd_import)
|
|
564
|
+
|
|
565
|
+
s = sub.add_parser("setup", help="Create ~/.trailmem/, init DB, download model")
|
|
566
|
+
s.set_defaults(func=cmd_setup)
|
|
567
|
+
s = sub.add_parser("integrate", help="Register MCP with detected agents (asks first)")
|
|
568
|
+
s.set_defaults(func=cmd_integrate)
|
|
569
|
+
s = sub.add_parser("doctor", help="Health check")
|
|
570
|
+
s.set_defaults(func=cmd_doctor)
|
|
571
|
+
|
|
572
|
+
s = sub.add_parser("model", help="Embedding model management")
|
|
573
|
+
ms = s.add_subparsers(dest="model_cmd", required=True)
|
|
574
|
+
ms.add_parser("list")
|
|
575
|
+
mi = ms.add_parser("install")
|
|
576
|
+
mi.add_argument("name", nargs="?")
|
|
577
|
+
mi.add_argument("--path")
|
|
578
|
+
mu = ms.add_parser("use")
|
|
579
|
+
mu.add_argument("name")
|
|
580
|
+
ms.add_parser("disable")
|
|
581
|
+
s.set_defaults(func=cmd_model)
|
|
582
|
+
|
|
583
|
+
s = sub.add_parser("reindex", help="Re-embed all memories with the active model")
|
|
584
|
+
s.set_defaults(func=cmd_reindex)
|
|
585
|
+
|
|
586
|
+
s = sub.add_parser("delete", help="Hard delete (NOT recommended — use archive)")
|
|
587
|
+
s.add_argument("ref")
|
|
588
|
+
s.add_argument("--hard", action="store_true")
|
|
589
|
+
s.add_argument("--confirm", action="store_true")
|
|
590
|
+
s.set_defaults(func=cmd_delete)
|
|
591
|
+
|
|
592
|
+
s = sub.add_parser("hook", help="Host lifecycle hooks (always exit 0)")
|
|
593
|
+
s.add_argument("event", choices=["session-start", "session-stop"])
|
|
594
|
+
s.add_argument("--agent")
|
|
595
|
+
s.set_defaults(func=cmd_hook)
|
|
596
|
+
|
|
597
|
+
argv = sys.argv[1:] if argv is None else argv
|
|
598
|
+
if not argv or argv[0] in ("help", "--help", "-h"):
|
|
599
|
+
p.print_help()
|
|
600
|
+
return 0
|
|
601
|
+
args = p.parse_args(argv)
|
|
602
|
+
try:
|
|
603
|
+
return args.func(args)
|
|
604
|
+
except ValidationError as e:
|
|
605
|
+
print(f"error: {e}", file=sys.stderr)
|
|
606
|
+
return 2
|
|
607
|
+
except Exception as e:
|
|
608
|
+
print(f"error: {e}", file=sys.stderr)
|
|
609
|
+
return 1
|
|
610
|
+
|
|
611
|
+
|
|
612
|
+
if __name__ == "__main__":
|
|
613
|
+
sys.exit(main())
|
trailmem/config.py
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"""Config + paths. ~/.trailmem/ holds config.json, trailmem.db, models/."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
TRAILMEM_HOME = Path(os.environ.get("TRAILMEM_HOME", Path.home() / ".trailmem"))
|
|
8
|
+
CONFIG_PATH = TRAILMEM_HOME / "config.json"
|
|
9
|
+
MODELS_DIR = TRAILMEM_HOME / "models"
|
|
10
|
+
|
|
11
|
+
DEFAULT_CONFIG = {
|
|
12
|
+
"embedding": {
|
|
13
|
+
"enabled": True,
|
|
14
|
+
"model": "bge-small",
|
|
15
|
+
"dimensions": 384,
|
|
16
|
+
# Per-model dedup bands — cosine distributions differ across models.
|
|
17
|
+
"dedup_warn": 0.85,
|
|
18
|
+
"dedup_block": 0.92,
|
|
19
|
+
},
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def db_path() -> Path:
|
|
24
|
+
return Path(os.environ.get("TRAILMEM_DB", TRAILMEM_HOME / "trailmem.db"))
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def load_config() -> dict:
|
|
28
|
+
if CONFIG_PATH.exists():
|
|
29
|
+
cfg = json.loads(CONFIG_PATH.read_text())
|
|
30
|
+
merged = {**DEFAULT_CONFIG, **cfg}
|
|
31
|
+
merged["embedding"] = {**DEFAULT_CONFIG["embedding"], **cfg.get("embedding", {})}
|
|
32
|
+
return merged
|
|
33
|
+
return json.loads(json.dumps(DEFAULT_CONFIG))
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def save_config(cfg: dict) -> None:
|
|
37
|
+
TRAILMEM_HOME.mkdir(parents=True, exist_ok=True)
|
|
38
|
+
CONFIG_PATH.write_text(json.dumps(cfg, indent=2) + "\n")
|