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 ADDED
@@ -0,0 +1,3 @@
1
+ """trailmem — local-first, graph-linked persistent memory for AI coding agents."""
2
+
3
+ __version__ = "0.1.0"
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")