claude-jacked 0.2.7__py3-none-any.whl → 0.2.9__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.
jacked/cli.py CHANGED
@@ -32,11 +32,18 @@ def setup_logging(verbose: bool = False):
32
32
  )
33
33
 
34
34
 
35
- def get_config() -> SmartForkConfig:
36
- """Load configuration from environment."""
35
+ def get_config(quiet: bool = False) -> Optional[SmartForkConfig]:
36
+ """Load configuration from environment.
37
+
38
+ Args:
39
+ quiet: If True, return None instead of printing error and exiting.
40
+ Used by hooks that should fail gracefully.
41
+ """
37
42
  try:
38
43
  return SmartForkConfig.from_env()
39
44
  except ValueError as e:
45
+ if quiet:
46
+ return None
40
47
  console.print(f"[red]Configuration error:[/red] {e}")
41
48
  console.print("\nSet these environment variables:")
42
49
  console.print(" QDRANT_CLAUDE_SESSIONS_ENDPOINT=<your-qdrant-url>")
@@ -63,7 +70,12 @@ def index(session: Optional[str], repo: Optional[str]):
63
70
  import os
64
71
  from jacked.indexer import SessionIndexer
65
72
 
66
- config = get_config()
73
+ # Try to get config quietly - if not configured, nudge and exit cleanly
74
+ config = get_config(quiet=True)
75
+ if config is None:
76
+ print("[jacked] Indexing skipped - run 'jacked configure' to set up Qdrant")
77
+ sys.exit(0)
78
+
67
79
  indexer = SessionIndexer(config)
68
80
 
69
81
  if session:
@@ -623,6 +635,8 @@ def _get_sound_command(hook_type: str) -> str:
623
635
 
624
636
  def _install_sound_hooks(existing: dict, settings_path: Path):
625
637
  """Install sound notification hooks."""
638
+ import json
639
+
626
640
  marker = _sound_hook_marker()
627
641
 
628
642
  # Notification hook
@@ -680,9 +694,280 @@ def _remove_sound_hooks(settings_path: Path) -> bool:
680
694
  return modified
681
695
 
682
696
 
697
+ def _get_behavioral_rules() -> str:
698
+ """Load behavioral rules from data file."""
699
+ rules_path = _get_data_root() / "rules" / "jacked_behaviors.md"
700
+ if not rules_path.exists():
701
+ raise FileNotFoundError(f"Behavioral rules not found: {rules_path}")
702
+ return rules_path.read_text(encoding="utf-8").strip()
703
+
704
+
705
+ def _behavioral_rules_marker() -> str:
706
+ """Start marker for jacked behavioral rules block."""
707
+ return "# jacked-behaviors-v2"
708
+
709
+
710
+ def _behavioral_rules_end_marker() -> str:
711
+ """End marker for jacked behavioral rules block."""
712
+ return "# end-jacked-behaviors"
713
+
714
+
715
+ def _install_behavioral_rules(claude_md_path: Path):
716
+ """Install behavioral rules into CLAUDE.md with marker boundaries.
717
+
718
+ - Show rules before writing, require confirmation
719
+ - Backup file before first modification
720
+ - Atomic write (build in memory, write once)
721
+ - Skip if already installed with same version
722
+ """
723
+ import shutil
724
+
725
+ try:
726
+ rules_text = _get_behavioral_rules()
727
+ except FileNotFoundError as e:
728
+ console.print(f"[red][FAIL][/red] {e}")
729
+ console.print("[yellow]Skipping behavioral rules installation[/yellow]")
730
+ return
731
+
732
+ start_marker = _behavioral_rules_marker()
733
+ end_marker = _behavioral_rules_end_marker()
734
+
735
+ # Read existing content
736
+ existing_content = ""
737
+ if claude_md_path.exists():
738
+ existing_content = claude_md_path.read_text(encoding="utf-8")
739
+
740
+ # Check if already installed (any version)
741
+ marker_prefix = "# jacked-behaviors-v"
742
+ has_start = marker_prefix in existing_content
743
+ has_end = end_marker in existing_content
744
+
745
+ # Orphaned marker detection: start without end (or end without start)
746
+ if has_start != has_end:
747
+ which = "start" if has_start else "end"
748
+ missing = "end" if has_start else "start"
749
+ console.print(f"[red][FAIL][/red] Found {which} marker but no {missing} marker in CLAUDE.md")
750
+ console.print("Your CLAUDE.md has a corrupted jacked rules block. Please fix it manually:")
751
+ console.print(f" Start marker: {start_marker}")
752
+ console.print(f" End marker: {end_marker}")
753
+ return
754
+
755
+ has_existing = has_start and has_end
756
+ if has_existing:
757
+ # Extract existing block (find the versioned start marker)
758
+ start_idx = existing_content.index(marker_prefix)
759
+ end_idx = existing_content.index(end_marker) + len(end_marker)
760
+ existing_block = existing_content[start_idx:end_idx].strip()
761
+
762
+ if existing_block == rules_text:
763
+ console.print("[yellow][-][/yellow] Behavioral rules already configured correctly")
764
+ return
765
+ else:
766
+ # Version upgrade needed
767
+ console.print("\n[bold]Behavioral rules update available:[/bold]")
768
+ console.print(f"[dim]{rules_text}[/dim]")
769
+ if not click.confirm("Update behavioral rules in CLAUDE.md?"):
770
+ console.print("[yellow][-][/yellow] Skipped behavioral rules update")
771
+ return
772
+
773
+ # Backup before modifying
774
+ backup_path = claude_md_path.with_suffix(".md.pre-jacked")
775
+ if not backup_path.exists():
776
+ shutil.copy2(claude_md_path, backup_path)
777
+ console.print(f"[dim]Backup: {backup_path}[/dim]")
778
+
779
+ # Replace the block (symmetric with _remove_behavioral_rules)
780
+ before = existing_content[:start_idx].rstrip("\n")
781
+ after = existing_content[end_idx:].lstrip("\n")
782
+ if before and after:
783
+ new_content = before + "\n\n" + rules_text + "\n\n" + after
784
+ elif before:
785
+ new_content = before + "\n\n" + rules_text + "\n"
786
+ else:
787
+ new_content = rules_text + "\n" + after if after else rules_text + "\n"
788
+ try:
789
+ claude_md_path.write_text(new_content, encoding="utf-8")
790
+ except PermissionError:
791
+ console.print(f"[red][FAIL][/red] Permission denied writing to {claude_md_path}")
792
+ console.print("Check file permissions and try again.")
793
+ return
794
+ console.print("[green][OK][/green] Updated behavioral rules to latest version")
795
+ return
796
+
797
+ # Fresh install - show and confirm
798
+ console.print("\n[bold]Proposed behavioral rules for ~/.claude/CLAUDE.md:[/bold]")
799
+ console.print(f"[dim]{rules_text}[/dim]")
800
+ if not click.confirm("Add these behavioral rules to your global CLAUDE.md?"):
801
+ console.print("[yellow][-][/yellow] Skipped behavioral rules")
802
+ return
803
+
804
+ # Backup before modifying (if file exists and no backup yet)
805
+ if claude_md_path.exists():
806
+ backup_path = claude_md_path.with_suffix(".md.pre-jacked")
807
+ if not backup_path.exists():
808
+ shutil.copy2(claude_md_path, backup_path)
809
+ console.print(f"[dim]Backup: {backup_path}[/dim]")
810
+
811
+ # Ensure parent directory exists
812
+ claude_md_path.parent.mkdir(parents=True, exist_ok=True)
813
+
814
+ # Build new content atomically
815
+ if existing_content and not existing_content.endswith("\n\n"):
816
+ if existing_content.endswith("\n"):
817
+ new_content = existing_content + "\n" + rules_text + "\n"
818
+ else:
819
+ new_content = existing_content + "\n\n" + rules_text + "\n"
820
+ else:
821
+ new_content = existing_content + rules_text + "\n"
822
+
823
+ try:
824
+ claude_md_path.write_text(new_content, encoding="utf-8")
825
+ except PermissionError:
826
+ console.print(f"[red][FAIL][/red] Permission denied writing to {claude_md_path}")
827
+ console.print("Check file permissions and try again.")
828
+ return
829
+ console.print("[green][OK][/green] Installed behavioral rules in CLAUDE.md")
830
+
831
+
832
+ def _remove_behavioral_rules(claude_md_path: Path) -> bool:
833
+ """Remove jacked behavioral rules block from CLAUDE.md.
834
+
835
+ Returns True if rules were found and removed.
836
+ """
837
+ if not claude_md_path.exists():
838
+ return False
839
+
840
+ content = claude_md_path.read_text(encoding="utf-8")
841
+ marker_prefix = "# jacked-behaviors-v"
842
+ end_marker = _behavioral_rules_end_marker()
843
+
844
+ if marker_prefix not in content or end_marker not in content:
845
+ return False
846
+
847
+ start_idx = content.index(marker_prefix)
848
+ end_idx = content.index(end_marker) + len(end_marker)
849
+
850
+ # Strip the block and any extra blank lines around it
851
+ before = content[:start_idx].rstrip("\n")
852
+ after = content[end_idx:].lstrip("\n")
853
+
854
+ if before and after:
855
+ new_content = before + "\n\n" + after
856
+ elif before:
857
+ new_content = before + "\n"
858
+ else:
859
+ new_content = after
860
+
861
+ try:
862
+ claude_md_path.write_text(new_content, encoding="utf-8")
863
+ except PermissionError:
864
+ console.print(f"[red][FAIL][/red] Permission denied writing to {claude_md_path}")
865
+ return False
866
+ return True
867
+
868
+
869
+ def _security_hook_marker() -> str:
870
+ """Marker to identify jacked security gatekeeper hooks."""
871
+ return "# jacked-security"
872
+
873
+
874
+ def _get_security_prompt() -> str:
875
+ """Load security gatekeeper prompt from data file."""
876
+ prompt_path = _get_data_root() / "prompts" / "security_gatekeeper.txt"
877
+ if not prompt_path.exists():
878
+ raise FileNotFoundError(f"Security prompt not found: {prompt_path}")
879
+ return prompt_path.read_text(encoding="utf-8")
880
+
881
+
882
+ def _install_security_hook(existing: dict, settings_path: Path):
883
+ """Install Opus-powered security gatekeeper hook for Bash commands.
884
+
885
+ Handles fresh install and version upgrades (detects stale prompts).
886
+ """
887
+ import json
888
+
889
+ marker = _security_hook_marker()
890
+
891
+ try:
892
+ prompt_text = _get_security_prompt()
893
+ except FileNotFoundError as e:
894
+ console.print(f"[red][FAIL][/red] {e}")
895
+ console.print("[yellow]Skipping security gatekeeper installation[/yellow]")
896
+ return
897
+
898
+ if "PermissionRequest" not in existing["hooks"]:
899
+ existing["hooks"]["PermissionRequest"] = []
900
+
901
+ # Check if already installed and whether it needs upgrading
902
+ hook_index = None
903
+ needs_upgrade = False
904
+ for i, hook_entry in enumerate(existing["hooks"]["PermissionRequest"]):
905
+ hook_str = str(hook_entry)
906
+ if marker in hook_str:
907
+ hook_index = i
908
+ # Check if installed prompt matches current version
909
+ for h in hook_entry.get("hooks", []):
910
+ installed_prompt = h.get("prompt", "")
911
+ if installed_prompt != prompt_text:
912
+ needs_upgrade = True
913
+ break
914
+
915
+ if hook_index is not None and not needs_upgrade:
916
+ console.print("[yellow][-][/yellow] Security gatekeeper hook already configured")
917
+ return
918
+
919
+ hook_entry = {
920
+ "matcher": "Bash",
921
+ "hooks": [{
922
+ "type": "prompt",
923
+ "prompt": prompt_text,
924
+ "model": "opus",
925
+ "timeout": 60,
926
+ }]
927
+ }
928
+
929
+ if hook_index is not None and needs_upgrade:
930
+ existing["hooks"]["PermissionRequest"][hook_index] = hook_entry
931
+ settings_path.write_text(json.dumps(existing, indent=2))
932
+ console.print("[green][OK][/green] Updated security gatekeeper prompt to latest version")
933
+ else:
934
+ existing["hooks"]["PermissionRequest"].append(hook_entry)
935
+ settings_path.parent.mkdir(parents=True, exist_ok=True)
936
+ settings_path.write_text(json.dumps(existing, indent=2))
937
+ console.print("[green][OK][/green] Installed security gatekeeper (Opus evaluates Bash commands)")
938
+
939
+
940
+ def _remove_security_hook(settings_path: Path) -> bool:
941
+ """Remove jacked security gatekeeper hook. Returns True if removed."""
942
+ import json
943
+
944
+ if not settings_path.exists():
945
+ return False
946
+
947
+ settings = json.loads(settings_path.read_text())
948
+ marker = _security_hook_marker()
949
+
950
+ if "PermissionRequest" not in settings.get("hooks", {}):
951
+ return False
952
+
953
+ before = len(settings["hooks"]["PermissionRequest"])
954
+ settings["hooks"]["PermissionRequest"] = [
955
+ h for h in settings["hooks"]["PermissionRequest"]
956
+ if marker not in str(h)
957
+ ]
958
+ if len(settings["hooks"]["PermissionRequest"]) < before:
959
+ settings_path.write_text(json.dumps(settings, indent=2))
960
+ console.print("[green][OK][/green] Removed security gatekeeper hook")
961
+ return True
962
+
963
+ return False
964
+
965
+
683
966
  @main.command()
684
967
  @click.option("--sounds", is_flag=True, help="Install sound notification hooks")
685
- def install(sounds: bool):
968
+ @click.option("--no-security", is_flag=True, help="Skip security gatekeeper hook")
969
+ @click.option("--no-rules", is_flag=True, help="Skip behavioral rules in CLAUDE.md")
970
+ def install(sounds: bool, no_security: bool, no_rules: bool):
686
971
  """Auto-install hook config, skill, agents, and commands."""
687
972
  import os
688
973
  import json
@@ -692,6 +977,7 @@ def install(sounds: bool):
692
977
  pkg_root = _get_data_root()
693
978
 
694
979
  # Hook configuration - assumes jacked is on PATH (installed via pipx)
980
+ # async: True runs indexing in background so Claude Code doesn't wait
695
981
  hook_config = {
696
982
  "hooks": {
697
983
  "Stop": [
@@ -700,7 +986,8 @@ def install(sounds: bool):
700
986
  "hooks": [
701
987
  {
702
988
  "type": "command",
703
- "command": 'jacked index --repo "$CLAUDE_PROJECT_DIR"'
989
+ "command": 'jacked index --repo "$CLAUDE_PROJECT_DIR"',
990
+ "async": True
704
991
  }
705
992
  ]
706
993
  }
@@ -726,19 +1013,31 @@ def install(sounds: bool):
726
1013
  if "Stop" not in existing["hooks"]:
727
1014
  existing["hooks"]["Stop"] = []
728
1015
 
729
- # Check if hook already exists
730
- hook_exists = any(
731
- "jacked" in str(h.get("hooks", []))
732
- for h in existing["hooks"]["Stop"]
733
- )
734
-
735
- if not hook_exists:
1016
+ # Check if hook already exists and if it needs updating
1017
+ hook_index = None
1018
+ needs_async_update = False
1019
+ for i, hook_entry in enumerate(existing["hooks"]["Stop"]):
1020
+ for h in hook_entry.get("hooks", []):
1021
+ if "jacked" in h.get("command", ""):
1022
+ hook_index = i
1023
+ # Check if async is missing or false
1024
+ if not h.get("async"):
1025
+ needs_async_update = True
1026
+ break
1027
+
1028
+ if hook_index is None:
1029
+ # No hook exists - add it
736
1030
  existing["hooks"]["Stop"].append(hook_config["hooks"]["Stop"][0])
737
1031
  settings_path.parent.mkdir(parents=True, exist_ok=True)
738
1032
  settings_path.write_text(json.dumps(existing, indent=2))
739
1033
  console.print(f"[green][OK][/green] Added Stop hook to {settings_path}")
1034
+ elif needs_async_update:
1035
+ # Hook exists but needs async: true
1036
+ existing["hooks"]["Stop"][hook_index] = hook_config["hooks"]["Stop"][0]
1037
+ settings_path.write_text(json.dumps(existing, indent=2))
1038
+ console.print(f"[green][OK][/green] Updated Stop hook with async: true")
740
1039
  else:
741
- console.print(f"[yellow][-][/yellow] Stop hook already exists in {settings_path}")
1040
+ console.print(f"[yellow][-][/yellow] Stop hook already configured correctly")
742
1041
 
743
1042
  # Copy skill file with Python path templating
744
1043
  # Claude Code expects skills in subdirectories with SKILL.md
@@ -754,29 +1053,59 @@ def install(sounds: bool):
754
1053
  else:
755
1054
  console.print(f"[yellow][-][/yellow] Skill file not found at {skill_src}")
756
1055
 
757
- # Copy agents
1056
+ # Copy agents (with conflict detection)
758
1057
  agents_src = pkg_root / "agents"
759
1058
  agents_dst = home / ".claude" / "agents"
760
1059
  if agents_src.exists():
761
1060
  agents_dst.mkdir(parents=True, exist_ok=True)
762
1061
  agent_count = 0
1062
+ skipped = 0
763
1063
  for agent_file in agents_src.glob("*.md"):
764
- shutil.copy(agent_file, agents_dst / agent_file.name)
1064
+ dst_file = agents_dst / agent_file.name
1065
+ src_content = agent_file.read_text(encoding="utf-8")
1066
+ if dst_file.exists():
1067
+ dst_content = dst_file.read_text(encoding="utf-8")
1068
+ if src_content == dst_content:
1069
+ skipped += 1
1070
+ continue # Same content, skip silently
1071
+ # Different content - ask before overwriting
1072
+ if not click.confirm(f"Agent '{agent_file.name}' exists with different content. Overwrite?"):
1073
+ console.print(f"[yellow][-][/yellow] Skipped {agent_file.name}")
1074
+ continue
1075
+ shutil.copy(agent_file, dst_file)
765
1076
  agent_count += 1
766
- console.print(f"[green][OK][/green] Installed {agent_count} agents")
1077
+ msg = f"[green][OK][/green] Installed {agent_count} agents"
1078
+ if skipped:
1079
+ msg += f" ({skipped} unchanged)"
1080
+ console.print(msg)
767
1081
  else:
768
1082
  console.print(f"[yellow][-][/yellow] Agents directory not found")
769
1083
 
770
- # Copy commands
1084
+ # Copy commands (with conflict detection)
771
1085
  commands_src = pkg_root / "commands"
772
1086
  commands_dst = home / ".claude" / "commands"
773
1087
  if commands_src.exists():
774
1088
  commands_dst.mkdir(parents=True, exist_ok=True)
775
1089
  cmd_count = 0
1090
+ skipped = 0
776
1091
  for cmd_file in commands_src.glob("*.md"):
777
- shutil.copy(cmd_file, commands_dst / cmd_file.name)
1092
+ dst_file = commands_dst / cmd_file.name
1093
+ src_content = cmd_file.read_text(encoding="utf-8")
1094
+ if dst_file.exists():
1095
+ dst_content = dst_file.read_text(encoding="utf-8")
1096
+ if src_content == dst_content:
1097
+ skipped += 1
1098
+ continue # Same content, skip silently
1099
+ # Different content - ask before overwriting
1100
+ if not click.confirm(f"Command '{cmd_file.name}' exists with different content. Overwrite?"):
1101
+ console.print(f"[yellow][-][/yellow] Skipped {cmd_file.name}")
1102
+ continue
1103
+ shutil.copy(cmd_file, dst_file)
778
1104
  cmd_count += 1
779
- console.print(f"[green][OK][/green] Installed {cmd_count} commands")
1105
+ msg = f"[green][OK][/green] Installed {cmd_count} commands"
1106
+ if skipped:
1107
+ msg += f" ({skipped} unchanged)"
1108
+ console.print(msg)
780
1109
  else:
781
1110
  console.print(f"[yellow][-][/yellow] Commands directory not found")
782
1111
 
@@ -784,13 +1113,30 @@ def install(sounds: bool):
784
1113
  if sounds:
785
1114
  _install_sound_hooks(existing, settings_path)
786
1115
 
1116
+ # Install security gatekeeper (default on, --no-security to skip)
1117
+ if not no_security:
1118
+ _install_security_hook(existing, settings_path)
1119
+
1120
+ # Install behavioral rules in CLAUDE.md (default on, --no-rules to skip)
1121
+ if not no_rules:
1122
+ claude_md_path = home / ".claude" / "CLAUDE.md"
1123
+ _install_behavioral_rules(claude_md_path)
1124
+
787
1125
  console.print("\n[bold]Installation complete![/bold]")
788
1126
  console.print("\n[yellow]IMPORTANT: Restart Claude Code for new commands to take effect![/yellow]")
789
1127
  console.print("\nWhat you get:")
790
1128
  console.print(" - /jacked - Search past Claude sessions")
791
- console.print(" - /dc - Double-check reviewer")
1129
+ console.print(" - /dc - Double-check reviewer (with grill mode)")
792
1130
  console.print(" - /pr - PR workflow helper")
1131
+ console.print(" - /learn - Distill lessons into CLAUDE.md rules")
1132
+ console.print(" - /techdebt - Project tech debt audit")
1133
+ console.print(" - /redo - Scrap and re-implement with hindsight")
1134
+ console.print(" - /audit-rules - CLAUDE.md quality audit")
793
1135
  console.print(" - 10 specialized agents (readme, wiki, tests, etc.)")
1136
+ if not no_security:
1137
+ console.print(" - Security gatekeeper (Opus evaluates Bash commands)")
1138
+ if not no_rules:
1139
+ console.print(" - Behavioral rules in CLAUDE.md (auto-triggers for jacked commands)")
794
1140
  console.print("\nNext steps:")
795
1141
  console.print(" 1. Restart Claude Code (exit and run 'claude' again)")
796
1142
  console.print(" 2. Set environment variables (run 'jacked configure' for help)")
@@ -801,7 +1147,9 @@ def install(sounds: bool):
801
1147
  @main.command()
802
1148
  @click.option("--yes", "-y", is_flag=True, help="Skip confirmation")
803
1149
  @click.option("--sounds", is_flag=True, help="Remove only sound hooks")
804
- def uninstall(yes: bool, sounds: bool):
1150
+ @click.option("--security", is_flag=True, help="Remove only security gatekeeper hook")
1151
+ @click.option("--rules", is_flag=True, help="Remove only behavioral rules from CLAUDE.md")
1152
+ def uninstall(yes: bool, sounds: bool, security: bool, rules: bool):
805
1153
  """Remove jacked hooks, skill, agents, and commands from Claude Code."""
806
1154
  import json
807
1155
  import shutil
@@ -818,6 +1166,23 @@ def uninstall(yes: bool, sounds: bool):
818
1166
  console.print("[yellow]No sound hooks found[/yellow]")
819
1167
  return
820
1168
 
1169
+ # If --security flag, only remove security hook
1170
+ if security:
1171
+ if _remove_security_hook(settings_path):
1172
+ console.print("[bold]Security gatekeeper removed![/bold]")
1173
+ else:
1174
+ console.print("[yellow]No security gatekeeper hook found[/yellow]")
1175
+ return
1176
+
1177
+ # If --rules flag, only remove behavioral rules
1178
+ if rules:
1179
+ claude_md_path = home / ".claude" / "CLAUDE.md"
1180
+ if _remove_behavioral_rules(claude_md_path):
1181
+ console.print("[bold]Behavioral rules removed from CLAUDE.md![/bold]")
1182
+ else:
1183
+ console.print("[yellow]No behavioral rules found in CLAUDE.md[/yellow]")
1184
+ return
1185
+
821
1186
  if not yes:
822
1187
  if not click.confirm("Remove jacked from Claude Code? (This won't delete your Qdrant index)"):
823
1188
  console.print("Cancelled")
@@ -825,8 +1190,12 @@ def uninstall(yes: bool, sounds: bool):
825
1190
 
826
1191
  console.print("[bold]Uninstalling Jacked...[/bold]\n")
827
1192
 
828
- # Also remove sound hooks during full uninstall
1193
+ # Also remove sound, security hooks, and behavioral rules during full uninstall
829
1194
  _remove_sound_hooks(settings_path)
1195
+ _remove_security_hook(settings_path)
1196
+ claude_md_path = home / ".claude" / "CLAUDE.md"
1197
+ if _remove_behavioral_rules(claude_md_path):
1198
+ console.print("[green][OK][/green] Removed behavioral rules from CLAUDE.md")
830
1199
 
831
1200
  # Remove Stop hook from settings.json
832
1201
  if settings_path.exists():
jacked/client.py CHANGED
@@ -55,11 +55,13 @@ class QdrantSessionClient:
55
55
 
56
56
  def ensure_collection(self) -> bool:
57
57
  """
58
- Ensure the collection exists, creating it if necessary.
58
+ Ensure the collection exists with all required indexes.
59
59
 
60
60
  Creates collection with:
61
61
  - Dense vectors for semantic search
62
- - Payload indexing for repo filtering
62
+ - Payload indexing for filtering
63
+
64
+ Also ensures indexes exist on existing collections (for upgrades).
63
65
 
64
66
  Returns:
65
67
  True if collection exists or was created
@@ -75,7 +77,9 @@ class QdrantSessionClient:
75
77
  exists = any(c.name == collection_name for c in collections.collections)
76
78
 
77
79
  if exists:
78
- logger.info(f"Collection '{collection_name}' already exists")
80
+ logger.debug(f"Collection '{collection_name}' already exists")
81
+ # Ensure indexes exist (handles upgrades)
82
+ self._ensure_indexes(collection_name)
79
83
  return True
80
84
 
81
85
  logger.info(f"Creating collection '{collection_name}'")
@@ -134,6 +138,30 @@ class QdrantSessionClient:
134
138
  logger.error(f"Failed to create collection: {e}")
135
139
  raise
136
140
 
141
+ def _ensure_indexes(self, collection_name: str):
142
+ """
143
+ Ensure all required payload indexes exist on a collection.
144
+
145
+ Creates indexes if they don't exist (idempotent).
146
+ """
147
+ required_indexes = [
148
+ "repo_id", "repo_name", "session_id", "type",
149
+ "machine", "user_name", "content_type"
150
+ ]
151
+
152
+ for field_name in required_indexes:
153
+ try:
154
+ self.client.create_payload_index(
155
+ collection_name=collection_name,
156
+ field_name=field_name,
157
+ field_schema=models.PayloadSchemaType.KEYWORD,
158
+ )
159
+ logger.debug(f"Created index for '{field_name}'")
160
+ except UnexpectedResponse as e:
161
+ # Index might already exist - that's fine
162
+ if "already exists" not in str(e).lower():
163
+ logger.warning(f"Could not create index for '{field_name}': {e}")
164
+
137
165
  def upsert_points(self, points: list[models.PointStruct]) -> bool:
138
166
  """
139
167
  Upsert points to the collection.
@@ -192,6 +220,48 @@ class QdrantSessionClient:
192
220
  logger.error(f"Failed to delete session {session_id}: {e}")
193
221
  raise
194
222
 
223
+ def get_session_points(self, session_id: str, user_name: str) -> list:
224
+ """
225
+ Get all points for a session owned by this user (for write tracker seeding).
226
+
227
+ IMPORTANT: Filters by BOTH session_id AND user_name to ensure we only
228
+ see our own data. This is for write-side tracking only - not for retrieval.
229
+
230
+ Args:
231
+ session_id: Session UUID
232
+ user_name: User name to filter by
233
+
234
+ Returns:
235
+ List of Qdrant points with payloads (no vectors)
236
+ """
237
+ points = []
238
+ offset = None
239
+ while True:
240
+ result = self.client.scroll(
241
+ collection_name=self.config.collection_name,
242
+ scroll_filter=models.Filter(
243
+ must=[
244
+ models.FieldCondition(
245
+ key="session_id",
246
+ match=models.MatchValue(value=session_id)
247
+ ),
248
+ models.FieldCondition(
249
+ key="user_name",
250
+ match=models.MatchValue(value=user_name)
251
+ )
252
+ ]
253
+ ),
254
+ limit=100,
255
+ offset=offset,
256
+ with_payload=True,
257
+ with_vectors=False, # Don't need vectors, just metadata
258
+ )
259
+ points.extend(result[0])
260
+ offset = result[1]
261
+ if offset is None:
262
+ break
263
+ return points
264
+
195
265
  def delete_by_user(self, user_name: str) -> int:
196
266
  """
197
267
  Delete all points for a specific user.
@@ -387,28 +457,6 @@ class QdrantSessionClient:
387
457
  logger.error(f"Failed to get points for session {session_id}: {e}")
388
458
  raise
389
459
 
390
- def get_point_by_id(self, point_id: str) -> Optional[models.Record]:
391
- """
392
- Get a single point by ID.
393
-
394
- Args:
395
- point_id: Point ID to retrieve
396
-
397
- Returns:
398
- Record object or None if not found
399
- """
400
- try:
401
- results = self.client.retrieve(
402
- collection_name=self.config.collection_name,
403
- ids=[point_id],
404
- with_payload=True,
405
- with_vectors=False,
406
- )
407
- return results[0] if results else None
408
- except UnexpectedResponse as e:
409
- logger.error(f"Failed to get point {point_id}: {e}")
410
- return None
411
-
412
460
  def list_sessions(
413
461
  self,
414
462
  repo_id: Optional[str] = None,
@@ -424,10 +472,11 @@ class QdrantSessionClient:
424
472
  Returns:
425
473
  List of session metadata dicts
426
474
  """
475
+ # Filter by plan content_type - one per session, gives unique sessions
427
476
  filter_conditions = [
428
477
  models.FieldCondition(
429
- key="type",
430
- match=models.MatchValue(value="intent"),
478
+ key="content_type",
479
+ match=models.MatchValue(value="plan"),
431
480
  )
432
481
  ]
433
482
 
@@ -455,9 +504,10 @@ class QdrantSessionClient:
455
504
  "session_id": payload.get("session_id"),
456
505
  "repo_name": payload.get("repo_name"),
457
506
  "repo_path": payload.get("repo_path"),
507
+ "user_name": payload.get("user_name"),
458
508
  "machine": payload.get("machine"),
459
509
  "timestamp": payload.get("timestamp"),
460
- "chunk_count": payload.get("transcript_chunk_count", 0),
510
+ "chunk_count": payload.get("total_chunks", 0),
461
511
  })
462
512
 
463
513
  return sessions