-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathcli.py
More file actions
2186 lines (1849 loc) · 84.6 KB
/
Copy pathcli.py
File metadata and controls
2186 lines (1849 loc) · 84.6 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
#!/usr/bin/env python3
"""
Base Layer CLI — Personal AI Memory System
Pipeline (5 steps): Import -> Extract -> Embed -> Author -> Compose
The 5-step sequence above is the canonical logical order documented in
docs/core/ARCHITECTURE.md. The one-command runner `baselayer run` groups embed
into a post-compose traceability phase together with tiering and verification
for efficiency; step-by-step usage (baselayer embed between extract and author)
runs the logical order directly. Both produce the same final artifacts.
Usage:
baselayer run <file> [-y] One-command pipeline: import > extract > author > compose > traceability
baselayer ui Local drag-and-drop web interface
baselayer init Initialize a fresh database
baselayer import <file> [--source X] Import conversations (chatgpt/claude/journal)
baselayer extract [--limit N] Extract facts from conversations
baselayer extract --identity-only Extract identity traits from Claude Code sessions
baselayer embed Generate vector embeddings (optional utility)
baselayer author [--layer X] Generate the three specification layers (requires Anthropic API key)
baselayer compose Compose unified specification from deployed layers (Opus API)
baselayer brief <message> Assemble a memory brief for a message
baselayer chat Interactive chat with memory-augmented Claude
baselayer checkpoint <stage> Quality gate reports (extraction/all)
baselayer batch-extract --submit Submit batch re-extraction (Anthropic Batch API, 50% cost)
baselayer batch-extract --status Check batch processing status
baselayer batch-extract --process Process completed batch results
baselayer stats Show database statistics
baselayer search <query> Search facts by keyword or semantics
baselayer review Review and correct facts interactively
baselayer forget --fact ID Soft-delete a specific fact by ID
baselayer forget --conversation ID Soft-delete all facts from a conversation
baselayer forget --all Soft-delete ALL facts (requires confirmation)
baselayer estimate Estimate API cost for extraction
baselayer rebuild-fts Rebuild FTS5 full-text search index
"""
import contextlib
import sys
import io
import os
import argparse
from pathlib import Path
# Fix Windows encoding — prevent UnicodeEncodeError on cp1252 stdout
if sys.stdout.encoding and sys.stdout.encoding.lower() != "utf-8":
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding="utf-8", errors="replace")
sys.stderr = io.TextIOWrapper(sys.stderr.buffer, encoding="utf-8", errors="replace")
def cmd_init(args):
"""Initialize a fresh Base Layer database."""
import json
from baselayer.config import PROJECT_ROOT, DATABASE_FILE
# Create directory structure
dirs = [
PROJECT_ROOT / "data" / "database",
PROJECT_ROOT / "data" / "vectors",
PROJECT_ROOT / "data" / "raw",
PROJECT_ROOT / "data" / "identity_layers",
PROJECT_ROOT / "data" / "imports",
]
for d in dirs:
d.mkdir(parents=True, exist_ok=True)
if DATABASE_FILE.exists() and not args.force:
print(f"Database already exists at {DATABASE_FILE}")
print("Use --force to reinitialize (WARNING: deletes all data)")
return
# --- Privacy disclosure ---
print()
print(" Privacy Notice:")
print(" Base Layer stores all data locally on your machine.")
print(" During fact extraction, conversation text is sent to the Anthropic API")
print(" (Claude Haiku) for processing. Your data is subject to Anthropic's API")
print(" data policy (zero-retention by default as of March 2025; policies may")
print(" change — see https://www.anthropic.com/policies/privacy).")
print()
print(" By continuing, you acknowledge this data processing.")
try:
consent = input(" Continue? [Y/n]: ").strip().lower()
except (EOFError, KeyboardInterrupt):
consent = "n"
if consent == "n":
print(" Setup cancelled.")
return
print()
# --- User name prompt ---
try:
name_input = input(" What name should the system use for you? ").strip()
except (EOFError, KeyboardInterrupt):
name_input = ""
user_names = [name_input] if name_input else []
# --- Pronoun prompt ---
print()
print(" Pronouns for the specification layers (used in third-person descriptions):")
print(" 1. he/him")
print(" 2. she/her")
print(" 3. they/them")
print(" 4. Custom")
try:
pronoun_choice = input(" Select [1-4] (default: 3): ").strip()
except (EOFError, KeyboardInterrupt):
pronoun_choice = ""
pronoun_map = {"1": "he/him", "2": "she/her", "3": "they/them"}
if pronoun_choice in pronoun_map:
user_pronouns = pronoun_map[pronoun_choice]
elif pronoun_choice == "4":
try:
user_pronouns = input(" Enter custom pronouns: ").strip()
except (EOFError, KeyboardInterrupt):
user_pronouns = ""
if not user_pronouns:
user_pronouns = "they/them"
else:
user_pronouns = "they/them"
print()
# --- Write entity_map.json ---
entity_map_path = PROJECT_ROOT / "data" / "entity_map.json"
if entity_map_path.exists():
with open(entity_map_path, "r", encoding="utf-8") as f:
entity_map = json.load(f)
entity_map["_user_names"] = user_names
entity_map["_user_pronouns"] = user_pronouns
else:
entity_map = {
"_user_names": user_names,
"_user_pronouns": user_pronouns,
}
with open(entity_map_path, "w", encoding="utf-8") as f:
json.dump(entity_map, f, indent=4)
print(f" Saved identity config to {entity_map_path}")
import baselayer.init_database as init_database
init_database.main()
print(f"\nBase Layer initialized at {PROJECT_ROOT}")
print(f"Database: {DATABASE_FILE}")
print(f"\nNext steps:")
print(f" Option A: Import existing conversations")
print(f" baselayer import <your-chatgpt-export.zip>")
print(f" Option B: Start from scratch with journal prompts")
print(f" baselayer journal")
print(f"\n Journal entries produce the highest-quality identity data.")
print(f" You can do both — import conversations AND journal.")
def cmd_import(args):
"""Import conversation history or text files."""
import baselayer.import_conversations as import_conversations
file_path = args.file
if not Path(file_path).exists():
print(f"Error: File not found: {file_path}")
sys.exit(1)
source = args.source
if not source:
# Auto-detect source from file extension and name
p = Path(file_path)
name = p.name.lower()
if p.is_dir() or p.suffix.lower() in (".txt", ".md", ".docx"):
source = "text"
elif "chatgpt" in name or name.endswith(".zip"):
source = "chatgpt"
elif "claude" in name:
source = "claude_web"
elif "journal" in name:
source = "journal"
elif p.suffix.lower() == ".json":
source = "json"
else:
source = "chatgpt" # Default
print(f"Auto-detected source: {source}")
if source == "text":
sys.argv = ["import_conversations.py", "--text", file_path]
elif source == "json":
sys.argv = ["import_conversations.py", "--json", file_path]
else:
sys.argv = ["import_conversations.py", f"--{source.replace('_', '-')}", file_path]
import_conversations.main()
def cmd_estimate(args):
"""Estimate API cost for extraction based on imported data."""
from baselayer.config import DATABASE_FILE, EXTRACTION_API_MODEL, get_db
if not DATABASE_FILE.exists():
print("No database found. Run: baselayer init")
sys.exit(1)
with contextlib.closing(get_db()) as conn:
# Count conversations awaiting extraction
total_convos = conn.execute("SELECT COUNT(*) FROM conversations").fetchone()[0]
extracted = conn.execute("SELECT COUNT(DISTINCT conversation_id) FROM extraction_log").fetchone()[0]
pending = total_convos - extracted
# Estimate tokens from message content
total_chars = conn.execute("""
SELECT COALESCE(SUM(LENGTH(content_text)), 0)
FROM messages m
JOIN conversations c ON m.conversation_id = c.id
WHERE c.id NOT IN (SELECT conversation_id FROM extraction_log)
""").fetchone()[0]
# Token estimation (4 chars per token)
input_tokens = total_chars // 4
# Output estimate: ~200 tokens per conversation (JSON facts)
output_tokens = pending * 200
# Pricing per 1M tokens (as of 2026)
pricing = {
"claude-haiku-4-5-20251001": {"input": 0.80, "output": 4.00, "name": "Haiku 4.5"},
"claude-sonnet-4-20250514": {"input": 3.00, "output": 15.00, "name": "Sonnet 4"},
"claude-opus-4-20250514": {"input": 15.00, "output": 75.00, "name": "Opus 4"},
}
print(f"\n Extraction Cost Estimate")
print(f" {'='*45}")
print(f" Total conversations: {total_convos:,}")
print(f" Already extracted: {extracted:,}")
print(f" Pending extraction: {pending:,}")
print(f" Estimated input tokens: {input_tokens:,}")
print(f" Estimated output tokens:{output_tokens:,}")
print(f"\n Estimated cost by model:")
for model_id, info in pricing.items():
input_cost = (input_tokens / 1_000_000) * info["input"]
output_cost = (output_tokens / 1_000_000) * info["output"]
total_cost = input_cost + output_cost
marker = " <-- current default" if model_id == EXTRACTION_API_MODEL else ""
print(f" {info['name']:12s} ${total_cost:>7.2f}{marker}")
print(f"\n Note: Actual cost depends on conversation length and")
print(f" fact density. Estimates are typically within 2x of actual.")
# Also show downstream pipeline costs
if pending > 0:
print(f"\n Post-extraction pipeline costs (one-time):")
print(f" Layer authoring (Sonnet): ~$0.10")
print(f" Brief composition (Opus): ~$0.20")
print(f" Total pipeline: ~$0.30")
print()
def cmd_extract(args):
"""Extract facts from imported conversations."""
from baselayer import config
# Privacy reminder for API extraction
print(" Note: Extraction sends conversation text to the Anthropic API for processing.")
# Override backend if specified
if args.backend:
config.EXTRACTION_BACKEND = args.backend
if config.EXTRACTION_BACKEND == "anthropic":
_check_api_key()
print(f"Using Anthropic API ({config.EXTRACTION_API_MODEL}) for extraction")
else:
print(f"Using local Ollama ({config.LLM_MODEL}) for extraction")
if args.identity_only:
print("Mode: IDENTITY-ONLY — extracting personal traits from project conversations")
import baselayer.extract_facts as extract_facts
argv = ["extract_facts.py"]
if args.limit:
argv.extend(["--limit", str(args.limit)])
if args.identity_only:
argv.append("--identity-only")
if args.source:
argv.extend(["--source", args.source])
if getattr(args, 'document_mode', False):
argv.append("--document-mode")
sys.argv = argv
extract_facts.main()
def cmd_embed(args):
"""Generate vector embeddings for facts and messages.
NOTE: embed.py is an optional utility from the pre-simplified pipeline.
It is no longer part of the default 4-step pipeline (Import -> Extract -> Author -> Compose).
Run directly: python scripts/embed.py
"""
import baselayer.embed as embed
sys.argv = ["embed.py"]
embed.main()
def cmd_author(args):
"""Generate the three specification layers (ANCHORS, CORE, PREDICTIONS)."""
_check_api_key()
_check_extraction_complete()
_check_fact_floor()
import baselayer.author_layers as author_layers
argv = ["author_layers.py", "--generate"]
if args.layer:
argv.append(args.layer)
else:
argv.append("all")
if getattr(args, 'no_citations', False):
argv.append("--no-citations")
sys.argv = argv
author_layers.main()
# Check if any layers were actually generated
from baselayer.config import ANCHORS_LAYER_FILE, CORE_LAYER_FILE, PREDICTIONS_LAYER_FILE
layers_exist = any(f.exists() for f in [ANCHORS_LAYER_FILE, CORE_LAYER_FILE, PREDICTIONS_LAYER_FILE])
if not layers_exist:
print("\nNo layers were generated. Your dataset may not have enough identity-tier facts yet.")
print("Run: baselayer stats")
return
# Chain compose step if requested
if getattr(args, 'compose', False):
print(f"\n{'='*60}")
print(f" Chaining unified brief composition...")
cmd_compose(args)
def cmd_compose(args):
"""Compose a unified narrative specification from the deployed layers."""
_check_api_key()
_check_extraction_complete()
_check_fact_floor()
from baselayer.agent_pipeline import compose_unified_brief
brief = compose_unified_brief()
if brief:
print(f"\n Unified brief composed successfully.")
print(f" Length: {len(brief)} chars, ~{len(brief) // 4} tokens")
else:
print(f"\n Composition failed. Check that layers are deployed.")
sys.exit(1)
def cmd_brief(args):
"""Assemble a memory brief for a message."""
import baselayer.assemble_brief as assemble_brief
sys.argv = ["assemble_brief.py", "--show-brief", args.message]
assemble_brief.main()
def cmd_chat(args):
"""Interactive chat with memory-augmented Claude."""
_check_api_key()
import baselayer.assemble_brief as assemble_brief
sys.argv = ["assemble_brief.py", "--interactive"]
assemble_brief.main()
def cmd_checkpoint(args):
"""Run pipeline quality checkpoint reports."""
from baselayer.checkpoint import run_checkpoint
run_checkpoint(args.stage)
def cmd_log(args):
"""Inspect MCP call logs for per-session call traces.
Each running MCP server keeps its own session directory under
~/.baselayer/sessions/<pid>/ with meta.json + count + log.jsonl.
Sessions persist on disk after the server exits (count is removed
on clean shutdown but log stays for analysis).
"""
import json as _json
from datetime import datetime
from pathlib import Path
sessions_root = Path.home() / ".baselayer" / "sessions"
if not sessions_root.exists():
print("No MCP sessions found. Has the server ever run?")
return
def _load_session(sess_dir):
meta_file = sess_dir / "meta.json"
if not meta_file.exists():
return None
try:
meta = _json.loads(meta_file.read_text(encoding="utf-8"))
except (OSError, _json.JSONDecodeError):
return None
meta["dir"] = sess_dir
meta["active"] = (sess_dir / "count").exists()
meta["count"] = 0
if meta["active"]:
try:
meta["count"] = int((sess_dir / "count").read_text(encoding="utf-8").strip() or "0")
except (ValueError, OSError):
meta["count"] = 0
log_file = sess_dir / "log.jsonl"
meta["log_lines"] = sum(1 for _ in log_file.open(encoding="utf-8")) if log_file.exists() else 0
return meta
sessions = []
for d in sorted(sessions_root.iterdir()):
if not d.is_dir():
continue
s = _load_session(d)
if s is not None:
sessions.append(s)
if not sessions:
print("No MCP sessions found.")
return
if args.action == "list":
print(f"{'PID':>8} {'PARENT':>8} {'STATE':<8} {'COUNT':>6} {'LOGGED':>6} STARTED CWD")
for s in sessions:
state = "active" if s["active"] else "ended"
print(f"{s['pid']:>8} {s.get('parent_pid', '-'):>8} {state:<8} "
f"{s['count']:>6} {s['log_lines']:>6} {s.get('start_time', '?'):<26} {s.get('cwd', '?')}")
return
if args.action == "stats":
active = sum(1 for s in sessions if s["active"])
total_calls = sum(s["log_lines"] for s in sessions)
by_tool = {}
for s in sessions:
log_file = s["dir"] / "log.jsonl"
if not log_file.exists():
continue
for line in log_file.open(encoding="utf-8"):
try:
e = _json.loads(line)
except _json.JSONDecodeError:
continue
by_tool[e["name"]] = by_tool.get(e["name"], 0) + 1
print(f"Sessions: {len(sessions)} ({active} active)")
print(f"Total calls logged: {total_calls}")
print("Calls by tool:")
for n, c in sorted(by_tool.items(), key=lambda x: -x[1]):
print(f" {n:30s} {c:>6}")
return
# show / tail need a session
pid = args.pid
if pid is None:
# default to most recent active, else most recent
active_sessions = [s for s in sessions if s["active"]]
target = active_sessions[-1] if active_sessions else sessions[-1]
pid = target["pid"]
print(f"(no --pid given; using {pid})\n")
target = next((s for s in sessions if s["pid"] == pid), None)
if target is None:
print(f"No session with PID {pid}. Run `baselayer log list` to see available sessions.")
return
log_file = target["dir"] / "log.jsonl"
if not log_file.exists():
print(f"No log file for session {pid}.")
return
entries = []
with log_file.open(encoding="utf-8") as f:
for line in f:
try:
entries.append(_json.loads(line))
except _json.JSONDecodeError:
continue
if args.action == "tail":
entries = entries[-args.limit:]
for e in entries:
ts = e.get("timestamp", "?")
name = e.get("name", "?")
args_d = e.get("args") or {}
reason = args_d.get("reason") or ""
rest = {k: v for k, v in args_d.items() if k != "reason"}
rest_str = ""
if rest:
rest_str = " " + " ".join(f"{k}={v!r}" for k, v in rest.items())
if reason:
print(f"[{ts}] {name}{rest_str}\n reason: {reason}")
else:
print(f"[{ts}] {name}{rest_str}")
def cmd_serve(args):
"""Toggle MCP spec serving without restarting Claude Code.
Writes a single character ('0' or '1') to ~/.baselayer/serving_enabled.
The MCP server reads this file on every resource read and tool call.
When disabled, the always-on resource and the layer tools (get_anchors,
get_predictions, get_brief) return a polite "disabled" message instead
of content. Other tools (recall_memories, search_facts, trace_claim,
verify_claims, get_stats) continue to work since they are fact-database
queries, not spec serving.
The MCP server does NOT need to restart for this to take effect; the
next call sees the new state.
"""
from pathlib import Path
state_file = Path.home() / ".baselayer" / "serving_enabled"
if args.action == "enable":
state_file.parent.mkdir(parents=True, exist_ok=True)
state_file.write_text("1", encoding="utf-8")
print("Spec serving: enabled")
elif args.action == "disable":
state_file.parent.mkdir(parents=True, exist_ok=True)
state_file.write_text("0", encoding="utf-8")
print("Spec serving: disabled")
elif args.action == "status":
if state_file.exists():
try:
value = state_file.read_text(encoding="utf-8").strip()
state = "enabled" if value != "0" else "disabled"
except OSError:
state = "enabled (default; state file unreadable)"
else:
state = "enabled (default; no state file)"
print(f"Spec serving: {state}")
def cmd_stats(args):
"""Show database statistics."""
from baselayer.config import DATABASE_FILE, get_db
if not DATABASE_FILE.exists():
print("No database found. Run: baselayer init")
sys.exit(1)
with contextlib.closing(get_db()) as conn:
convos = conn.execute("SELECT COUNT(*) FROM conversations").fetchone()[0]
messages = conn.execute("SELECT COUNT(*) FROM messages").fetchone()[0]
facts = conn.execute("SELECT COUNT(*) FROM memory_facts WHERE superseded_by IS NULL").fetchone()[0]
superseded = conn.execute("SELECT COUNT(*) FROM memory_facts WHERE superseded_by IS NOT NULL").fetchone()[0]
# Tier breakdown
tiers = conn.execute("""
SELECT knowledge_tier, COUNT(*) as cnt
FROM memory_facts WHERE superseded_by IS NULL
GROUP BY knowledge_tier ORDER BY cnt DESC
""").fetchall()
# Sources
sources = conn.execute("""
SELECT source, COUNT(*) as cnt
FROM conversations GROUP BY source ORDER BY cnt DESC
""").fetchall()
print(f"\n Base Layer Database Statistics")
print(f" {'='*40}")
print(f" Conversations: {convos:,}")
print(f" Messages: {messages:,}")
print(f" Active facts: {facts:,}")
print(f" Superseded: {superseded:,}")
if tiers:
print(f"\n Knowledge Tiers:")
for t in tiers:
tier_name = t["knowledge_tier"] or "unclassified"
print(f" {tier_name:15s} {t['cnt']:,}")
if sources:
print(f"\n Sources:")
for s in sources:
print(f" {s['source']:15s} {s['cnt']:,}")
print()
def cmd_search(args):
"""Search facts by keyword or semantic similarity."""
import baselayer.semantic_search as semantic_search
sys.argv = ["semantic_search.py", args.query]
semantic_search.main()
def _delete_vectors(fact_ids):
"""Delete fact vectors from ChromaDB. Returns count of vectors removed."""
from baselayer.config import VECTORS_DIR
if not fact_ids:
return 0
try:
import chromadb
except ImportError:
print(" WARNING: chromadb not available. Skipping vector cleanup.")
return 0
try:
client = chromadb.PersistentClient(path=str(VECTORS_DIR))
collection = client.get_collection("memory_facts")
except Exception as e:
# Collection doesn't exist — nothing to delete
print(f"WARNING: Could not access memory_facts collection: {e}", file=sys.stderr)
return 0
# ChromaDB delete only works with IDs that exist — filter first
existing = set()
for fid in fact_ids:
try:
result = collection.get(ids=[fid])
if result and result["ids"]:
existing.add(fid)
except Exception:
pass
if existing:
collection.delete(ids=list(existing))
return len(existing)
def cmd_forget(args):
"""Soft-delete facts by ID, conversation, or all. Removes corresponding vectors."""
import time
from baselayer.config import DATABASE_FILE, get_db
if not DATABASE_FILE.exists():
print("No database found. Run: baselayer init")
sys.exit(1)
# Determine mode
if args.all:
mode = "all"
elif args.fact:
mode = "fact"
elif args.conversation:
mode = "conversation"
else:
print("Specify one of: --fact ID, --conversation ID, or --all")
sys.exit(1)
with contextlib.closing(get_db()) as conn:
if mode == "fact":
# Single fact by ID
row = conn.execute(
"SELECT id, fact_text FROM memory_facts WHERE id = ? AND superseded_by IS NULL",
(args.fact,)
).fetchone()
if not row:
print(f"No active fact found with ID: {args.fact}")
return
fact_ids = [row["id"]]
print(f"\n Fact: {row['fact_text'][:120]}")
print(f" ID: {row['id']}")
elif mode == "conversation":
rows = conn.execute(
"SELECT id, fact_text FROM memory_facts"
" WHERE source_conversation_id = ? AND superseded_by IS NULL",
(args.conversation,)
).fetchall()
if not rows:
print(f"No active facts found for conversation: {args.conversation}")
return
fact_ids = [r["id"] for r in rows]
print(f"\n Found {len(rows)} active facts from conversation {args.conversation}")
elif mode == "all":
rows = conn.execute(
"SELECT id FROM memory_facts WHERE superseded_by IS NULL"
).fetchall()
if not rows:
print("No active facts to delete.")
return
fact_ids = [r["id"] for r in rows]
print(f"\n This will soft-delete ALL {len(rows)} active facts.")
try:
confirm = input(" Type DELETE to confirm: ").strip()
except (EOFError, KeyboardInterrupt):
confirm = ""
if confirm != "DELETE":
print(" Cancelled.")
return
# Soft-delete in SQLite
now = time.time()
with conn:
for fid in fact_ids:
conn.execute(
"UPDATE memory_facts SET superseded_by = 'user_forget', updated_at = ?"
" WHERE id = ? AND superseded_by IS NULL",
(now, fid),
)
# Delete vectors from ChromaDB
vectors_removed = _delete_vectors(fact_ids)
print(f"\n Soft-deleted {len(fact_ids)} fact(s).")
print(f" Removed {vectors_removed} vector(s) from ChromaDB.")
print(f" Facts are hidden but not permanently deleted.")
print(f" They can be recovered by clearing the superseded_by field.")
def cmd_provenance(args):
"""Show provenance chain for specification claims."""
from baselayer.config import DATABASE_FILE, get_db
if not DATABASE_FILE.exists():
print("No database found. Run: baselayer init")
sys.exit(1)
with contextlib.closing(get_db()) as conn:
# Check if provenance table exists
table_exists = conn.execute(
"SELECT name FROM sqlite_master WHERE type='table' AND name='layer_claim_provenance'"
).fetchone()
if not table_exists:
print("No provenance data found. Run: baselayer author (provenance is captured at authoring time)")
return
if args.claim:
# Trace a specific claim
rows = conn.execute("""
SELECT p.claim_id, p.claim_text, p.fact_id, p.link_method,
p.rank_in_claim, p.layer_name, p.layer_version,
f.fact_text, f.fact_type, f.knowledge_tier,
f.recurrence_count, f.source_conversation_id
FROM layer_claim_provenance p
LEFT JOIN memory_facts f ON p.fact_id = f.id
WHERE UPPER(p.claim_id) = UPPER(?)
ORDER BY p.rank_in_claim
""", (args.claim,)).fetchall()
if not rows:
print(f"No provenance found for claim '{args.claim}'")
return
first = rows[0]
print(f"\n Claim: {first['claim_id']} — {first['claim_text'] or '(unnamed)'}")
print(f" Layer: {first['layer_name']} ({first['layer_version'] or 'unknown'})")
print(f"\n Supporting Facts ({len(rows)}):")
for r in rows:
fact_text = r["fact_text"] or "(fact not found in DB)"
tier = r["knowledge_tier"] or "?"
rec = r["recurrence_count"] or 0
print(f" [{r['rank_in_claim']}] {fact_text[:150]}")
print(f" tier: {tier}, recurrence: {rec}, linked via: {r['link_method']}")
if r["source_conversation_id"]:
conv = conn.execute(
"SELECT title FROM conversations WHERE id = ?",
(r["source_conversation_id"],)
).fetchone()
if conv and conv["title"]:
print(f" source: \"{conv['title'][:80]}\"")
else:
# Summary mode — show all claims with fact counts
rows = conn.execute("""
SELECT layer_name, claim_id, claim_text, COUNT(*) as fact_count,
layer_version
FROM layer_claim_provenance
GROUP BY layer_name, claim_id
ORDER BY layer_name,
CASE SUBSTR(claim_id, 1, 1)
WHEN 'A' THEN 1 WHEN 'M' THEN 2
WHEN 'C' THEN 3 WHEN 'P' THEN 4
ELSE 5
END,
CAST(SUBSTR(claim_id, 2) AS INTEGER)
""").fetchall()
if not rows:
print("No provenance entries found.")
return
total_facts = sum(r["fact_count"] for r in rows)
print(f"\n Provenance Summary ({len(rows)} claims, {total_facts} fact links)")
print()
current_layer = None
for r in rows:
if r["layer_name"] != current_layer:
current_layer = r["layer_name"]
print(f" {current_layer} ({r['layer_version'] or '?'}):")
claim_text = r["claim_text"] or "(unnamed)"
print(f" {r['claim_id']:4s} {claim_text[:60]:60s} ({r['fact_count']} facts)")
print(f"\n Use: baselayer provenance --claim A1 (to trace a specific claim)")
def cmd_verify(args):
"""Verify specification layer provenance (vector similarity + claim checks)."""
from baselayer.config import DATABASE_FILE
if not DATABASE_FILE.exists():
print("No database found. Run: baselayer init")
sys.exit(1)
from baselayer.verify_provenance import (
vector_audit,
run_verification,
run_full_verification,
run_nli_verification,
format_vector_results,
format_claim_results,
format_coverage_results,
format_nli_results,
_check_coverage,
)
layer = (args.layer or "all").upper()
print(f"\n Base Layer Provenance Verification")
print(f" {'=' * 50}")
if args.generate:
# Generate vector provenance from scratch (S68)
from baselayer.verify_provenance import generate_vector_provenance
if layer == "ALL":
layers = ["ANCHORS", "CORE", "PREDICTIONS"]
else:
layers = [layer]
for lname in layers:
print(f"\n Generating vector provenance for {lname}...")
results = generate_vector_provenance(lname)
if not results:
print(f" No provenance generated for {lname}")
return
elif args.nli:
# NLI-only mode (DeBERTa entailment check)
print(f"\n Running NLI entailment verification...")
nli_results = run_nli_verification(layer, claim_id_filter=args.claim)
print(format_nli_results(nli_results))
elif args.vector:
# Vector audit only (topic proximity check)
# S6: pass claim_id_filter so --vector --claim A1 works consistently
if layer == "ALL":
layers = ["ANCHORS", "CORE", "PREDICTIONS"]
else:
layers = [layer]
vector_results = {}
for lname in layers:
print(f"\n Running vector audit for {lname}...")
vr = vector_audit(lname, claim_id_filter=args.claim)
if vr:
vector_results[lname] = vr
if vector_results:
print(format_vector_results(vector_results))
else:
print(" No vector results (no provenance citations or embeddings found)")
elif args.claims:
# S9: Claims-only mode (skip vector audit)
print(f"\n Running claim verification only (--claims mode)...")
summary = run_verification(layer, claim_id_filter=args.claim)
print(format_claim_results(summary))
# Include coverage when not filtering to a single claim
if not args.claim:
coverage = _check_coverage(
layer if layer != "ALL" else None
)
print(format_coverage_results(coverage))
elif args.claim:
# Specific claim verification
print(f"\n Verifying claim: {args.claim}")
summary = run_verification(layer, claim_id_filter=args.claim)
print(format_claim_results(summary))
else:
# Full verification (vector + claims)
result = run_full_verification(layer, claim_id_filter=None)
if result["vector"]:
print(format_vector_results(result["vector"]))
print(format_claim_results(result["claims"]))
# Coverage (C11)
if result.get("coverage"):
print(format_coverage_results(result["coverage"]))
print()
def cmd_review(args):
"""Interactive fact review — inspect, approve, or flag facts."""
from baselayer.config import DATABASE_FILE, get_db
if not DATABASE_FILE.exists():
print("No database found. Run: baselayer init")
sys.exit(1)
with contextlib.closing(get_db()) as conn:
# Determine what to review
tier_filter = args.tier
limit = args.limit or 50
conditions = ["superseded_by IS NULL"]
params = []
if tier_filter:
conditions.append("knowledge_tier = ?")
params.append(tier_filter)
# Safety: WHERE clause is built from a fixed set of parameterized conditions,
# not from user input. All dynamic values go through ? placeholders.
where = " AND ".join(conditions)
params.append(limit)
rows = conn.execute(
"SELECT id, fact_text, category, knowledge_tier, fact_type,"
" commitment_depth, recurrence_count, significance_score"
" FROM memory_facts"
" WHERE " + where +
" ORDER BY"
" CASE knowledge_tier"
" WHEN 'identity' THEN 1"
" WHEN 'situational' THEN 2"
" WHEN 'context' THEN 3"
" ELSE 4"
" END,"
" significance_score DESC"
" LIMIT ?",
params
).fetchall()
if not rows:
print("No facts to review.")
return
print(f"\n Base Layer Fact Review")
print(f" {'='*50}")
print(f" Showing {len(rows)} facts{f' (tier: {tier_filter})' if tier_filter else ''}")
print(f" Commands: [enter]=next, d=delete, f=flag, q=quit\n")
deleted = 0
flagged = 0
reviewed = 0
for i, r in enumerate(rows):
tier = r["knowledge_tier"] or "?"
ftype = r["fact_type"] or "?"
depth = r["commitment_depth"] or "?"
cat = r["category"] or "?"
rec = r["recurrence_count"] or 0
sig = r["significance_score"] or 0
print(f" [{i+1}/{len(rows)}] {r['fact_text']}")
print(f" tier={tier} type={ftype} depth={depth} cat={cat} rec={rec} sig={sig:.1f}")
try:
action = input(" > ").strip().lower()
except (EOFError, KeyboardInterrupt):
print("\n Quitting.")
break
if action == "q":
break
elif action == "d":
import time
with conn:
conn.execute("""
UPDATE memory_facts
SET superseded_by = 'user_review', updated_at = ?
WHERE id = ?
""", (time.time(), r["id"]))
deleted += 1
print(" Deleted.")
elif action == "f":
note = input(" Flag note: ").strip()
with conn:
conn.execute("""
INSERT OR REPLACE INTO user_corrections
(id, correction_type, original_fact_id, original_fact_text,
annotation, created_at)
VALUES (?, 'flag', ?, ?, ?, ?)
""", (r["id"] + "_flag", r["id"], r["fact_text"], note, time.time()))
flagged += 1
print(" Flagged.")
else:
reviewed += 1
print()