npcsh 1.1.17__py3-none-any.whl → 1.1.18__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.
Files changed (169) hide show
  1. npcsh/_state.py +114 -91
  2. npcsh/alicanto.py +2 -2
  3. npcsh/benchmark/__init__.py +8 -2
  4. npcsh/benchmark/npcsh_agent.py +46 -12
  5. npcsh/benchmark/runner.py +85 -43
  6. npcsh/benchmark/templates/install-npcsh.sh.j2 +35 -0
  7. npcsh/build.py +2 -4
  8. npcsh/completion.py +2 -6
  9. npcsh/config.py +1 -3
  10. npcsh/conversation_viewer.py +389 -0
  11. npcsh/corca.py +0 -1
  12. npcsh/execution.py +0 -1
  13. npcsh/guac.py +0 -1
  14. npcsh/mcp_helpers.py +2 -3
  15. npcsh/mcp_server.py +5 -10
  16. npcsh/npc.py +10 -11
  17. npcsh/npc_team/jinxs/bin/benchmark.jinx +1 -1
  18. npcsh/npc_team/jinxs/lib/core/search/db_search.jinx +321 -17
  19. npcsh/npc_team/jinxs/lib/core/search/file_search.jinx +312 -67
  20. npcsh/npc_team/jinxs/lib/core/search/kg_search.jinx +366 -44
  21. npcsh/npc_team/jinxs/lib/core/search/mem_review.jinx +73 -0
  22. npcsh/npc_team/jinxs/lib/core/search/mem_search.jinx +328 -20
  23. npcsh/npc_team/jinxs/lib/core/search/web_search.jinx +242 -10
  24. npcsh/npc_team/jinxs/lib/core/sleep.jinx +22 -11
  25. npcsh/npc_team/jinxs/lib/core/sql.jinx +10 -6
  26. npcsh/npc_team/jinxs/lib/research/paper_search.jinx +387 -76
  27. npcsh/npc_team/jinxs/lib/research/semantic_scholar.jinx +372 -55
  28. npcsh/npc_team/jinxs/lib/utils/jinxs.jinx +299 -144
  29. npcsh/npc_team/jinxs/modes/alicanto.jinx +356 -0
  30. npcsh/npc_team/jinxs/modes/arxiv.jinx +720 -0
  31. npcsh/npc_team/jinxs/modes/corca.jinx +430 -0
  32. npcsh/npc_team/jinxs/modes/guac.jinx +544 -0
  33. npcsh/npc_team/jinxs/modes/plonk.jinx +379 -0
  34. npcsh/npc_team/jinxs/modes/pti.jinx +357 -0
  35. npcsh/npc_team/jinxs/modes/reattach.jinx +291 -0
  36. npcsh/npc_team/jinxs/modes/spool.jinx +350 -0
  37. npcsh/npc_team/jinxs/modes/wander.jinx +455 -0
  38. npcsh/npc_team/jinxs/{bin → modes}/yap.jinx +13 -7
  39. npcsh/npcsh.py +7 -4
  40. npcsh/plonk.py +0 -1
  41. npcsh/pti.py +0 -1
  42. npcsh/routes.py +1 -3
  43. npcsh/spool.py +0 -1
  44. npcsh/ui.py +0 -1
  45. npcsh/wander.py +0 -1
  46. npcsh/yap.py +0 -1
  47. npcsh-1.1.18.data/data/npcsh/npc_team/alicanto.jinx +356 -0
  48. npcsh-1.1.18.data/data/npcsh/npc_team/arxiv.jinx +720 -0
  49. {npcsh-1.1.17.data → npcsh-1.1.18.data}/data/npcsh/npc_team/benchmark.jinx +1 -1
  50. npcsh-1.1.18.data/data/npcsh/npc_team/corca.jinx +430 -0
  51. npcsh-1.1.18.data/data/npcsh/npc_team/db_search.jinx +348 -0
  52. npcsh-1.1.18.data/data/npcsh/npc_team/file_search.jinx +339 -0
  53. npcsh-1.1.18.data/data/npcsh/npc_team/guac.jinx +544 -0
  54. npcsh-1.1.18.data/data/npcsh/npc_team/jinxs.jinx +331 -0
  55. npcsh-1.1.18.data/data/npcsh/npc_team/kg_search.jinx +418 -0
  56. npcsh-1.1.18.data/data/npcsh/npc_team/mem_review.jinx +73 -0
  57. npcsh-1.1.18.data/data/npcsh/npc_team/mem_search.jinx +388 -0
  58. npcsh-1.1.18.data/data/npcsh/npc_team/paper_search.jinx +412 -0
  59. npcsh-1.1.18.data/data/npcsh/npc_team/plonk.jinx +379 -0
  60. npcsh-1.1.18.data/data/npcsh/npc_team/pti.jinx +357 -0
  61. npcsh-1.1.18.data/data/npcsh/npc_team/reattach.jinx +291 -0
  62. npcsh-1.1.18.data/data/npcsh/npc_team/semantic_scholar.jinx +386 -0
  63. {npcsh-1.1.17.data → npcsh-1.1.18.data}/data/npcsh/npc_team/sleep.jinx +22 -11
  64. npcsh-1.1.18.data/data/npcsh/npc_team/spool.jinx +350 -0
  65. npcsh-1.1.18.data/data/npcsh/npc_team/sql.jinx +20 -0
  66. npcsh-1.1.18.data/data/npcsh/npc_team/wander.jinx +455 -0
  67. npcsh-1.1.18.data/data/npcsh/npc_team/web_search.jinx +283 -0
  68. {npcsh-1.1.17.data → npcsh-1.1.18.data}/data/npcsh/npc_team/yap.jinx +13 -7
  69. {npcsh-1.1.17.dist-info → npcsh-1.1.18.dist-info}/METADATA +90 -1
  70. npcsh-1.1.18.dist-info/RECORD +235 -0
  71. {npcsh-1.1.17.dist-info → npcsh-1.1.18.dist-info}/WHEEL +1 -1
  72. {npcsh-1.1.17.dist-info → npcsh-1.1.18.dist-info}/entry_points.txt +0 -3
  73. npcsh/npc_team/jinxs/bin/spool.jinx +0 -161
  74. npcsh/npc_team/jinxs/bin/wander.jinx +0 -242
  75. npcsh/npc_team/jinxs/lib/research/arxiv.jinx +0 -76
  76. npcsh-1.1.17.data/data/npcsh/npc_team/arxiv.jinx +0 -76
  77. npcsh-1.1.17.data/data/npcsh/npc_team/db_search.jinx +0 -44
  78. npcsh-1.1.17.data/data/npcsh/npc_team/file_search.jinx +0 -94
  79. npcsh-1.1.17.data/data/npcsh/npc_team/jinxs.jinx +0 -176
  80. npcsh-1.1.17.data/data/npcsh/npc_team/kg_search.jinx +0 -96
  81. npcsh-1.1.17.data/data/npcsh/npc_team/mem_search.jinx +0 -80
  82. npcsh-1.1.17.data/data/npcsh/npc_team/paper_search.jinx +0 -101
  83. npcsh-1.1.17.data/data/npcsh/npc_team/semantic_scholar.jinx +0 -69
  84. npcsh-1.1.17.data/data/npcsh/npc_team/spool.jinx +0 -161
  85. npcsh-1.1.17.data/data/npcsh/npc_team/sql.jinx +0 -16
  86. npcsh-1.1.17.data/data/npcsh/npc_team/wander.jinx +0 -242
  87. npcsh-1.1.17.data/data/npcsh/npc_team/web_search.jinx +0 -51
  88. npcsh-1.1.17.dist-info/RECORD +0 -219
  89. {npcsh-1.1.17.data → npcsh-1.1.18.data}/data/npcsh/npc_team/add_tab.jinx +0 -0
  90. {npcsh-1.1.17.data → npcsh-1.1.18.data}/data/npcsh/npc_team/alicanto.npc +0 -0
  91. {npcsh-1.1.17.data → npcsh-1.1.18.data}/data/npcsh/npc_team/alicanto.png +0 -0
  92. {npcsh-1.1.17.data → npcsh-1.1.18.data}/data/npcsh/npc_team/browser_action.jinx +0 -0
  93. {npcsh-1.1.17.data → npcsh-1.1.18.data}/data/npcsh/npc_team/browser_screenshot.jinx +0 -0
  94. {npcsh-1.1.17.data → npcsh-1.1.18.data}/data/npcsh/npc_team/build.jinx +0 -0
  95. {npcsh-1.1.17.data → npcsh-1.1.18.data}/data/npcsh/npc_team/chat.jinx +0 -0
  96. {npcsh-1.1.17.data → npcsh-1.1.18.data}/data/npcsh/npc_team/click.jinx +0 -0
  97. {npcsh-1.1.17.data → npcsh-1.1.18.data}/data/npcsh/npc_team/close_browser.jinx +0 -0
  98. {npcsh-1.1.17.data → npcsh-1.1.18.data}/data/npcsh/npc_team/close_pane.jinx +0 -0
  99. {npcsh-1.1.17.data → npcsh-1.1.18.data}/data/npcsh/npc_team/close_tab.jinx +0 -0
  100. {npcsh-1.1.17.data → npcsh-1.1.18.data}/data/npcsh/npc_team/cmd.jinx +0 -0
  101. {npcsh-1.1.17.data → npcsh-1.1.18.data}/data/npcsh/npc_team/compile.jinx +0 -0
  102. {npcsh-1.1.17.data → npcsh-1.1.18.data}/data/npcsh/npc_team/compress.jinx +0 -0
  103. {npcsh-1.1.17.data → npcsh-1.1.18.data}/data/npcsh/npc_team/confirm.jinx +0 -0
  104. {npcsh-1.1.17.data → npcsh-1.1.18.data}/data/npcsh/npc_team/convene.jinx +0 -0
  105. {npcsh-1.1.17.data → npcsh-1.1.18.data}/data/npcsh/npc_team/corca.npc +0 -0
  106. {npcsh-1.1.17.data → npcsh-1.1.18.data}/data/npcsh/npc_team/corca.png +0 -0
  107. {npcsh-1.1.17.data → npcsh-1.1.18.data}/data/npcsh/npc_team/corca_example.png +0 -0
  108. {npcsh-1.1.17.data → npcsh-1.1.18.data}/data/npcsh/npc_team/delegate.jinx +0 -0
  109. {npcsh-1.1.17.data → npcsh-1.1.18.data}/data/npcsh/npc_team/edit_file.jinx +0 -0
  110. {npcsh-1.1.17.data → npcsh-1.1.18.data}/data/npcsh/npc_team/focus_pane.jinx +0 -0
  111. {npcsh-1.1.17.data → npcsh-1.1.18.data}/data/npcsh/npc_team/frederic.npc +0 -0
  112. {npcsh-1.1.17.data → npcsh-1.1.18.data}/data/npcsh/npc_team/frederic4.png +0 -0
  113. {npcsh-1.1.17.data → npcsh-1.1.18.data}/data/npcsh/npc_team/guac.npc +0 -0
  114. {npcsh-1.1.17.data → npcsh-1.1.18.data}/data/npcsh/npc_team/guac.png +0 -0
  115. {npcsh-1.1.17.data → npcsh-1.1.18.data}/data/npcsh/npc_team/help.jinx +0 -0
  116. {npcsh-1.1.17.data → npcsh-1.1.18.data}/data/npcsh/npc_team/incognide.jinx +0 -0
  117. {npcsh-1.1.17.data → npcsh-1.1.18.data}/data/npcsh/npc_team/init.jinx +0 -0
  118. {npcsh-1.1.17.data → npcsh-1.1.18.data}/data/npcsh/npc_team/kadiefa.npc +0 -0
  119. {npcsh-1.1.17.data → npcsh-1.1.18.data}/data/npcsh/npc_team/kadiefa.png +0 -0
  120. {npcsh-1.1.17.data → npcsh-1.1.18.data}/data/npcsh/npc_team/key_press.jinx +0 -0
  121. {npcsh-1.1.17.data → npcsh-1.1.18.data}/data/npcsh/npc_team/launch_app.jinx +0 -0
  122. {npcsh-1.1.17.data → npcsh-1.1.18.data}/data/npcsh/npc_team/list_panes.jinx +0 -0
  123. {npcsh-1.1.17.data → npcsh-1.1.18.data}/data/npcsh/npc_team/load_file.jinx +0 -0
  124. {npcsh-1.1.17.data → npcsh-1.1.18.data}/data/npcsh/npc_team/navigate.jinx +0 -0
  125. {npcsh-1.1.17.data → npcsh-1.1.18.data}/data/npcsh/npc_team/notify.jinx +0 -0
  126. {npcsh-1.1.17.data → npcsh-1.1.18.data}/data/npcsh/npc_team/npcsh.ctx +0 -0
  127. {npcsh-1.1.17.data → npcsh-1.1.18.data}/data/npcsh/npc_team/npcsh_sibiji.png +0 -0
  128. {npcsh-1.1.17.data → npcsh-1.1.18.data}/data/npcsh/npc_team/nql.jinx +0 -0
  129. {npcsh-1.1.17.data → npcsh-1.1.18.data}/data/npcsh/npc_team/open_browser.jinx +0 -0
  130. {npcsh-1.1.17.data → npcsh-1.1.18.data}/data/npcsh/npc_team/open_pane.jinx +0 -0
  131. {npcsh-1.1.17.data → npcsh-1.1.18.data}/data/npcsh/npc_team/ots.jinx +0 -0
  132. {npcsh-1.1.17.data → npcsh-1.1.18.data}/data/npcsh/npc_team/paste.jinx +0 -0
  133. {npcsh-1.1.17.data → npcsh-1.1.18.data}/data/npcsh/npc_team/plonk.npc +0 -0
  134. {npcsh-1.1.17.data → npcsh-1.1.18.data}/data/npcsh/npc_team/plonk.png +0 -0
  135. {npcsh-1.1.17.data → npcsh-1.1.18.data}/data/npcsh/npc_team/plonkjr.npc +0 -0
  136. {npcsh-1.1.17.data → npcsh-1.1.18.data}/data/npcsh/npc_team/plonkjr.png +0 -0
  137. {npcsh-1.1.17.data → npcsh-1.1.18.data}/data/npcsh/npc_team/python.jinx +0 -0
  138. {npcsh-1.1.17.data → npcsh-1.1.18.data}/data/npcsh/npc_team/read_pane.jinx +0 -0
  139. {npcsh-1.1.17.data → npcsh-1.1.18.data}/data/npcsh/npc_team/roll.jinx +0 -0
  140. {npcsh-1.1.17.data → npcsh-1.1.18.data}/data/npcsh/npc_team/run_terminal.jinx +0 -0
  141. {npcsh-1.1.17.data → npcsh-1.1.18.data}/data/npcsh/npc_team/sample.jinx +0 -0
  142. {npcsh-1.1.17.data → npcsh-1.1.18.data}/data/npcsh/npc_team/screenshot.jinx +0 -0
  143. {npcsh-1.1.17.data → npcsh-1.1.18.data}/data/npcsh/npc_team/search.jinx +0 -0
  144. {npcsh-1.1.17.data → npcsh-1.1.18.data}/data/npcsh/npc_team/send_message.jinx +0 -0
  145. {npcsh-1.1.17.data → npcsh-1.1.18.data}/data/npcsh/npc_team/serve.jinx +0 -0
  146. {npcsh-1.1.17.data → npcsh-1.1.18.data}/data/npcsh/npc_team/set.jinx +0 -0
  147. {npcsh-1.1.17.data → npcsh-1.1.18.data}/data/npcsh/npc_team/sh.jinx +0 -0
  148. {npcsh-1.1.17.data → npcsh-1.1.18.data}/data/npcsh/npc_team/shh.jinx +0 -0
  149. {npcsh-1.1.17.data → npcsh-1.1.18.data}/data/npcsh/npc_team/sibiji.npc +0 -0
  150. {npcsh-1.1.17.data → npcsh-1.1.18.data}/data/npcsh/npc_team/sibiji.png +0 -0
  151. {npcsh-1.1.17.data → npcsh-1.1.18.data}/data/npcsh/npc_team/split_pane.jinx +0 -0
  152. {npcsh-1.1.17.data → npcsh-1.1.18.data}/data/npcsh/npc_team/spool.png +0 -0
  153. {npcsh-1.1.17.data → npcsh-1.1.18.data}/data/npcsh/npc_team/switch.jinx +0 -0
  154. {npcsh-1.1.17.data → npcsh-1.1.18.data}/data/npcsh/npc_team/switch_npc.jinx +0 -0
  155. {npcsh-1.1.17.data → npcsh-1.1.18.data}/data/npcsh/npc_team/switch_tab.jinx +0 -0
  156. {npcsh-1.1.17.data → npcsh-1.1.18.data}/data/npcsh/npc_team/switches.jinx +0 -0
  157. {npcsh-1.1.17.data → npcsh-1.1.18.data}/data/npcsh/npc_team/sync.jinx +0 -0
  158. {npcsh-1.1.17.data → npcsh-1.1.18.data}/data/npcsh/npc_team/teamviz.jinx +0 -0
  159. {npcsh-1.1.17.data → npcsh-1.1.18.data}/data/npcsh/npc_team/trigger.jinx +0 -0
  160. {npcsh-1.1.17.data → npcsh-1.1.18.data}/data/npcsh/npc_team/type_text.jinx +0 -0
  161. {npcsh-1.1.17.data → npcsh-1.1.18.data}/data/npcsh/npc_team/usage.jinx +0 -0
  162. {npcsh-1.1.17.data → npcsh-1.1.18.data}/data/npcsh/npc_team/verbose.jinx +0 -0
  163. {npcsh-1.1.17.data → npcsh-1.1.18.data}/data/npcsh/npc_team/vixynt.jinx +0 -0
  164. {npcsh-1.1.17.data → npcsh-1.1.18.data}/data/npcsh/npc_team/wait.jinx +0 -0
  165. {npcsh-1.1.17.data → npcsh-1.1.18.data}/data/npcsh/npc_team/write_file.jinx +0 -0
  166. {npcsh-1.1.17.data → npcsh-1.1.18.data}/data/npcsh/npc_team/yap.png +0 -0
  167. {npcsh-1.1.17.data → npcsh-1.1.18.data}/data/npcsh/npc_team/zen_mode.jinx +0 -0
  168. {npcsh-1.1.17.dist-info → npcsh-1.1.18.dist-info}/licenses/LICENSE +0 -0
  169. {npcsh-1.1.17.dist-info → npcsh-1.1.18.dist-info}/top_level.txt +0 -0
@@ -1,44 +1,348 @@
1
1
  jinx_name: db_search
2
- description: Search conversation history database using brainblast
2
+ description: Search conversation history database with interactive TUI
3
3
  inputs:
4
4
  - query: ""
5
- - db_path: ""
6
- - limit: "20"
5
+ - path: ""
6
+ - limit: "100"
7
+ - text: "false"
7
8
 
8
9
  steps:
9
10
  - name: search_db
10
11
  engine: python
11
12
  code: |
12
13
  import os
14
+ import sys
15
+ import tty
16
+ import termios
17
+ from datetime import datetime
18
+ from sqlalchemy import create_engine, text
13
19
 
14
20
  query = context.get('query', '').strip()
21
+ text_mode = context.get('text', '').lower() in ('true', '1', 'yes')
22
+
15
23
  if not query:
16
24
  lines = [
17
25
  "Usage: /db_search <query>",
18
26
  "",
19
- "Searches conversation history using brainblast for semantic matching.",
27
+ "Searches conversation history for matching content.",
20
28
  "",
21
29
  "Options:",
22
- " db_path - Path to history database",
23
- " limit - Max results to return (default 20)",
30
+ " path - Filter by directory path",
31
+ " limit - Max results (default 100)",
32
+ " text - Text-only output, no TUI (true/false)",
33
+ "",
34
+ "TUI Controls:",
35
+ " j/k or arrows - Navigate",
36
+ " 1/2/3 - Sort by time/role/npc",
37
+ " p - Preview full message",
38
+ " r - Reattach to conversation",
39
+ " f - Filter by role (user/assistant/all)",
40
+ " q/ESC - Quit",
24
41
  "",
25
42
  "Examples:",
26
43
  " /db_search python debugging",
27
- " /db_search api errors limit=50",
44
+ " /db_search api errors path=/home/user/project",
45
+ " /db_search errors text=true",
28
46
  ]
29
47
  context['output'] = "\n".join(lines)
30
48
  else:
31
- db_path = context.get('db_path') or os.path.expanduser("~/.npcsh/npcsh_history.db")
32
- limit = int(context.get('limit') or 20)
49
+ db_path = os.getenv("NPCSH_DB_PATH", os.path.expanduser("~/npcsh_history.db"))
50
+ limit = int(context.get('limit') or 100)
51
+ filter_path = context.get('path', '').strip()
52
+
53
+ engine = create_engine(f'sqlite:///{db_path}')
33
54
 
34
55
  try:
35
- cmd_history = CommandHistory(db_path)
36
- result = execute_brainblast_command(
37
- command=query,
38
- command_history=cmd_history,
39
- limit=limit
40
- )
41
- context['output'] = result.get('output', 'Brainblast search completed.')
56
+ with engine.connect() as conn:
57
+ if filter_path:
58
+ result = conn.execute(text("""
59
+ SELECT conversation_id, timestamp, role, content, npc, directory_path
60
+ FROM conversation_history
61
+ WHERE LOWER(content) LIKE LOWER(:query)
62
+ AND (directory_path = :path OR directory_path LIKE :path_pattern)
63
+ ORDER BY timestamp DESC
64
+ LIMIT :limit
65
+ """), {
66
+ "query": f"%{query}%",
67
+ "path": filter_path,
68
+ "path_pattern": filter_path + "/%",
69
+ "limit": limit
70
+ })
71
+ else:
72
+ result = conn.execute(text("""
73
+ SELECT conversation_id, timestamp, role, content, npc, directory_path
74
+ FROM conversation_history
75
+ WHERE LOWER(content) LIKE LOWER(:query)
76
+ ORDER BY timestamp DESC
77
+ LIMIT :limit
78
+ """), {"query": f"%{query}%", "limit": limit})
79
+
80
+ rows = [dict(row._mapping) for row in result.fetchall()]
81
+
82
+ if not rows:
83
+ context['output'] = f"No results found for '{query}'"
84
+ elif text_mode:
85
+ # Text-only output
86
+ lines = [f"Found {len(rows)} results for '{query}':", ""]
87
+ for row in rows:
88
+ cid = row['conversation_id'][:8] if row['conversation_id'] else '?'
89
+ ts = str(row['timestamp'])[:16] if row['timestamp'] else '?'
90
+ role = row['role'] or '?'
91
+ content = (row['content'] or '')[:100].replace('\n', ' ')
92
+ npc_name = row['npc'] or 'default'
93
+ path = row['directory_path'] or ''
94
+
95
+ lines.append(f"[{cid}] {ts} ({role}/{npc_name})")
96
+ lines.append(f" {content}")
97
+ if path:
98
+ lines.append(f" @ {path}")
99
+ lines.append("")
100
+ context['output'] = "\n".join(lines)
101
+ else:
102
+ # Interactive TUI mode
103
+ def get_terminal_size():
104
+ try:
105
+ size = os.get_terminal_size()
106
+ return size.columns, size.lines
107
+ except:
108
+ return 80, 24
109
+
110
+ def format_ts(ts):
111
+ if not ts:
112
+ return 'unknown'
113
+ try:
114
+ if 'T' in str(ts):
115
+ dt = datetime.fromisoformat(str(ts).replace('Z', '+00:00'))
116
+ else:
117
+ dt = datetime.strptime(str(ts)[:19], '%Y-%m-%d %H:%M:%S')
118
+ now = datetime.now()
119
+ diff = now - dt.replace(tzinfo=None)
120
+ if diff.days == 0:
121
+ return f"Today {dt.strftime('%H:%M')}"
122
+ elif diff.days == 1:
123
+ return f"Yesterday {dt.strftime('%H:%M')}"
124
+ elif diff.days < 7:
125
+ return dt.strftime('%a %H:%M')
126
+ else:
127
+ return dt.strftime('%b %d %H:%M')
128
+ except:
129
+ return str(ts)[:16]
130
+
131
+ width, height = get_terminal_size()
132
+ selected = 0
133
+ scroll = 0
134
+ list_height = height - 5
135
+ mode = 'list'
136
+ preview_scroll = 0
137
+ sort_mode = 'time' # time, role, npc
138
+ role_filter = 'all' # all, user, assistant
139
+
140
+ def sort_rows(rows, sort_mode):
141
+ if sort_mode == 'time':
142
+ return sorted(rows, key=lambda x: x.get('timestamp') or '', reverse=True)
143
+ elif sort_mode == 'role':
144
+ return sorted(rows, key=lambda x: (x.get('role') or '', x.get('timestamp') or ''), reverse=True)
145
+ elif sort_mode == 'npc':
146
+ return sorted(rows, key=lambda x: (x.get('npc') or '', x.get('timestamp') or ''), reverse=True)
147
+ return rows
148
+
149
+ def filter_rows(rows, role_filter):
150
+ if role_filter == 'all':
151
+ return rows
152
+ return [r for r in rows if r.get('role') == role_filter]
153
+
154
+ display_rows = filter_rows(sort_rows(rows, sort_mode), role_filter)
155
+
156
+ fd = sys.stdin.fileno()
157
+ old_settings = termios.tcgetattr(fd)
158
+
159
+ try:
160
+ tty.setcbreak(fd)
161
+ sys.stdout.write('\033[?25l')
162
+ sys.stdout.write('\033[2J\033[H')
163
+
164
+ while True:
165
+ width, height = get_terminal_size()
166
+ list_height = height - 5
167
+
168
+ if mode == 'list':
169
+ if selected < scroll:
170
+ scroll = selected
171
+ elif selected >= scroll + list_height:
172
+ scroll = selected - list_height + 1
173
+
174
+ sys.stdout.write('\033[H')
175
+
176
+ # Header
177
+ if mode == 'list':
178
+ sort_ind = {'time': '1', 'role': '2', 'npc': '3'}[sort_mode]
179
+ header = f" DB SEARCH ({len(display_rows)} results): '{query}' [sort:{sort_mode}({sort_ind}) filter:{role_filter}] "
180
+ else:
181
+ header = f" PREVIEW: {display_rows[selected]['conversation_id'][:16]} "
182
+ sys.stdout.write(f'\033[44;37;1m{header.ljust(width)}\033[0m\n')
183
+
184
+ # Column headers
185
+ if mode == 'list':
186
+ col_header = f' {"TIMESTAMP":<16} {"ROLE":<10} {"NPC":<12} {"CONTENT":<40}'
187
+ sys.stdout.write(f'\033[90m{col_header[:width]}\033[0m\n')
188
+ else:
189
+ sys.stdout.write(f'\033[90m{"─" * width}\033[0m\n')
190
+
191
+ if mode == 'list':
192
+ for i in range(list_height):
193
+ idx = scroll + i
194
+ sys.stdout.write(f'\033[{3+i};1H\033[K')
195
+ if idx >= len(display_rows):
196
+ continue
197
+
198
+ r = display_rows[idx]
199
+ ts = format_ts(r.get('timestamp'))
200
+ role = (r.get('role') or '?')[:10]
201
+ npc_name = (r.get('npc') or 'default')[:12]
202
+ content = (r.get('content') or '')[:60].replace('\n', ' ')
203
+
204
+ # Color by role
205
+ if r.get('role') == 'user':
206
+ role_color = '\033[32m' # green
207
+ elif r.get('role') == 'assistant':
208
+ role_color = '\033[34m' # blue
209
+ else:
210
+ role_color = '\033[90m' # gray
211
+
212
+ line = f" {ts:<16} {role_color}{role:<10}\033[0m {npc_name:<12} {content}"
213
+ line = line[:width+20] # allow for color codes
214
+
215
+ if idx == selected:
216
+ sys.stdout.write(f'\033[47;30;1m>{line}\033[0m')
217
+ else:
218
+ sys.stdout.write(f' {line}')
219
+
220
+ # Status bar
221
+ sys.stdout.write(f'\033[{height-2};1H\033[K\033[90m{"─" * width}\033[0m')
222
+ sel = display_rows[selected] if display_rows else {}
223
+ cid = sel.get('conversation_id', '')[:20]
224
+ path = sel.get('directory_path', '')
225
+ if len(path) > 40:
226
+ path = '...' + path[-37:]
227
+ sys.stdout.write(f'\033[{height-1};1H\033[K {cid} @ {path}'.ljust(width))
228
+ sys.stdout.write(f'\033[{height};1H\033[K\033[44;37m j/k:Nav 1/2/3:Sort f:Filter p:Preview r:Reattach q:Quit [{selected+1}/{len(display_rows)}] \033[0m')
229
+
230
+ else: # preview mode
231
+ sel = display_rows[selected]
232
+ content = sel.get('content') or ''
233
+ lines = content.split('\n')
234
+
235
+ for i in range(list_height):
236
+ idx = preview_scroll + i
237
+ sys.stdout.write(f'\033[{3+i};1H\033[K')
238
+ if idx < len(lines):
239
+ sys.stdout.write(lines[idx][:width-1])
240
+
241
+ sys.stdout.write(f'\033[{height-2};1H\033[K\033[90m{"─" * width}\033[0m')
242
+ role = sel.get('role') or '?'
243
+ npc_name = sel.get('npc') or 'default'
244
+ ts = format_ts(sel.get('timestamp'))
245
+ sys.stdout.write(f'\033[{height-1};1H\033[K {role}/{npc_name} - {ts} [{preview_scroll+1}/{len(lines)} lines]')
246
+ sys.stdout.write(f'\033[{height};1H\033[K\033[44;37m j/k:Scroll b:Back r:Reattach q:Quit \033[0m')
247
+
248
+ sys.stdout.flush()
249
+
250
+ c = sys.stdin.read(1)
251
+
252
+ if c == '\x1b':
253
+ c2 = sys.stdin.read(1)
254
+ if c2 == '[':
255
+ c3 = sys.stdin.read(1)
256
+ if c3 == 'A': # Up
257
+ if mode == 'list' and selected > 0:
258
+ selected -= 1
259
+ elif mode == 'preview' and preview_scroll > 0:
260
+ preview_scroll -= 1
261
+ elif c3 == 'B': # Down
262
+ if mode == 'list' and selected < len(display_rows) - 1:
263
+ selected += 1
264
+ elif mode == 'preview':
265
+ sel = display_rows[selected]
266
+ lines = (sel.get('content') or '').split('\n')
267
+ if preview_scroll < max(0, len(lines) - list_height):
268
+ preview_scroll += 1
269
+ else:
270
+ if mode == 'preview':
271
+ mode = 'list'
272
+ sys.stdout.write('\033[2J\033[H')
273
+ else:
274
+ context['output'] = "Cancelled."
275
+ break
276
+ continue
277
+
278
+ if c == 'q' or c == '\x03':
279
+ context['output'] = "Cancelled."
280
+ break
281
+ elif c == 'k':
282
+ if mode == 'list' and selected > 0:
283
+ selected -= 1
284
+ elif mode == 'preview' and preview_scroll > 0:
285
+ preview_scroll -= 1
286
+ elif c == 'j':
287
+ if mode == 'list' and selected < len(display_rows) - 1:
288
+ selected += 1
289
+ elif mode == 'preview':
290
+ sel = display_rows[selected]
291
+ lines = (sel.get('content') or '').split('\n')
292
+ if preview_scroll < max(0, len(lines) - list_height):
293
+ preview_scroll += 1
294
+ elif c == '1':
295
+ sort_mode = 'time'
296
+ display_rows = filter_rows(sort_rows(rows, sort_mode), role_filter)
297
+ selected = 0
298
+ scroll = 0
299
+ elif c == '2':
300
+ sort_mode = 'role'
301
+ display_rows = filter_rows(sort_rows(rows, sort_mode), role_filter)
302
+ selected = 0
303
+ scroll = 0
304
+ elif c == '3':
305
+ sort_mode = 'npc'
306
+ display_rows = filter_rows(sort_rows(rows, sort_mode), role_filter)
307
+ selected = 0
308
+ scroll = 0
309
+ elif c == 'f' and mode == 'list':
310
+ # Cycle through filters
311
+ if role_filter == 'all':
312
+ role_filter = 'user'
313
+ elif role_filter == 'user':
314
+ role_filter = 'assistant'
315
+ else:
316
+ role_filter = 'all'
317
+ display_rows = filter_rows(sort_rows(rows, sort_mode), role_filter)
318
+ selected = 0
319
+ scroll = 0
320
+ elif c == 'p' and mode == 'list' and display_rows:
321
+ mode = 'preview'
322
+ preview_scroll = 0
323
+ sys.stdout.write('\033[2J\033[H')
324
+ elif c == 'b' and mode == 'preview':
325
+ mode = 'list'
326
+ sys.stdout.write('\033[2J\033[H')
327
+ elif c == 'r' and display_rows:
328
+ cid = display_rows[selected]['conversation_id']
329
+ if 'state' in dir() and state is not None:
330
+ state.conversation_id = cid
331
+ context['output'] = f"Reattached to: {cid}"
332
+ else:
333
+ context['output'] = f"Selected: {cid}\n\nRun: /set conversation_id={cid}"
334
+ break
335
+ elif c in ('\r', '\n') and display_rows:
336
+ cid = display_rows[selected]['conversation_id']
337
+ context['output'] = f"Selected: {cid}\n\nRun: /set conversation_id={cid}"
338
+ break
339
+
340
+ finally:
341
+ termios.tcsetattr(fd, termios.TCSADRAIN, old_settings)
342
+ sys.stdout.write('\033[?25h')
343
+ sys.stdout.write('\033[2J\033[H')
344
+ sys.stdout.flush()
345
+
42
346
  except Exception as e:
43
347
  import traceback
44
- context['output'] = "DB search error: " + str(e) + "\n" + traceback.format_exc()
348
+ context['output'] = f"Search error: {e}\n{traceback.format_exc()}"