npcsh 1.1.19__py3-none-any.whl → 1.1.20__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.
- npcsh/_state.py +11 -7
- npcsh/npc_team/jinxs/bin/config_tui.jinx +3 -2
- npcsh/npc_team/jinxs/bin/jinxs.jinx +407 -0
- npcsh/npc_team/jinxs/bin/kg.jinx +941 -0
- npcsh/npc_team/jinxs/bin/memories.jinx +3 -2
- npcsh/npc_team/jinxs/bin/models.jinx +343 -0
- npcsh/npc_team/jinxs/bin/nql.jinx +380 -50
- npcsh/npc_team/jinxs/bin/setup.jinx +2 -1
- npcsh/npc_team/jinxs/bin/team.jinx +504 -0
- npcsh/npc_team/jinxs/lib/core/search/db_search.jinx +1 -1
- npcsh/npc_team/jinxs/lib/core/search/file_search.jinx +1 -1
- npcsh/npc_team/jinxs/lib/core/search/kg_search.jinx +1 -1
- npcsh/npc_team/jinxs/lib/core/search/mem_search.jinx +1 -1
- npcsh/npc_team/jinxs/lib/core/search/web_search.jinx +1 -1
- npcsh/npc_team/jinxs/lib/research/paper_search.jinx +1 -1
- npcsh/npc_team/jinxs/lib/research/semantic_scholar.jinx +1 -1
- npcsh/npc_team/jinxs/modes/alicanto.jinx +1 -1
- npcsh/npc_team/jinxs/modes/arxiv.jinx +1 -1
- npcsh/npc_team/jinxs/modes/corca.jinx +1 -1
- npcsh/npc_team/jinxs/modes/guac.jinx +4 -4
- npcsh/npc_team/jinxs/modes/plonk.jinx +1 -1
- npcsh/npc_team/jinxs/modes/pti.jinx +1 -1
- npcsh/npc_team/jinxs/modes/reattach.jinx +1 -1
- npcsh/npc_team/jinxs/modes/spool.jinx +1 -1
- npcsh/npc_team/jinxs/modes/wander.jinx +1 -1
- npcsh/routes.py +8 -2
- {npcsh-1.1.19.data → npcsh-1.1.20.data}/data/npcsh/npc_team/alicanto.jinx +1 -1
- {npcsh-1.1.19.data → npcsh-1.1.20.data}/data/npcsh/npc_team/arxiv.jinx +1 -1
- {npcsh-1.1.19.data → npcsh-1.1.20.data}/data/npcsh/npc_team/config_tui.jinx +3 -2
- {npcsh-1.1.19.data → npcsh-1.1.20.data}/data/npcsh/npc_team/corca.jinx +1 -1
- {npcsh-1.1.19.data → npcsh-1.1.20.data}/data/npcsh/npc_team/db_search.jinx +1 -1
- {npcsh-1.1.19.data → npcsh-1.1.20.data}/data/npcsh/npc_team/file_search.jinx +1 -1
- {npcsh-1.1.19.data → npcsh-1.1.20.data}/data/npcsh/npc_team/guac.jinx +4 -4
- npcsh-1.1.20.data/data/npcsh/npc_team/jinxs.jinx +407 -0
- npcsh-1.1.20.data/data/npcsh/npc_team/kg.jinx +941 -0
- {npcsh-1.1.19.data → npcsh-1.1.20.data}/data/npcsh/npc_team/kg_search.jinx +1 -1
- {npcsh-1.1.19.data → npcsh-1.1.20.data}/data/npcsh/npc_team/mem_search.jinx +1 -1
- {npcsh-1.1.19.data → npcsh-1.1.20.data}/data/npcsh/npc_team/memories.jinx +3 -2
- npcsh-1.1.20.data/data/npcsh/npc_team/models.jinx +343 -0
- npcsh-1.1.20.data/data/npcsh/npc_team/nql.jinx +471 -0
- {npcsh-1.1.19.data → npcsh-1.1.20.data}/data/npcsh/npc_team/paper_search.jinx +1 -1
- {npcsh-1.1.19.data → npcsh-1.1.20.data}/data/npcsh/npc_team/plonk.jinx +1 -1
- {npcsh-1.1.19.data → npcsh-1.1.20.data}/data/npcsh/npc_team/pti.jinx +1 -1
- {npcsh-1.1.19.data → npcsh-1.1.20.data}/data/npcsh/npc_team/reattach.jinx +1 -1
- {npcsh-1.1.19.data → npcsh-1.1.20.data}/data/npcsh/npc_team/semantic_scholar.jinx +1 -1
- {npcsh-1.1.19.data → npcsh-1.1.20.data}/data/npcsh/npc_team/setup.jinx +2 -1
- {npcsh-1.1.19.data → npcsh-1.1.20.data}/data/npcsh/npc_team/spool.jinx +1 -1
- npcsh-1.1.20.data/data/npcsh/npc_team/team.jinx +504 -0
- {npcsh-1.1.19.data → npcsh-1.1.20.data}/data/npcsh/npc_team/wander.jinx +1 -1
- {npcsh-1.1.19.data → npcsh-1.1.20.data}/data/npcsh/npc_team/web_search.jinx +1 -1
- {npcsh-1.1.19.dist-info → npcsh-1.1.20.dist-info}/METADATA +1 -1
- {npcsh-1.1.19.dist-info → npcsh-1.1.20.dist-info}/RECORD +139 -135
- {npcsh-1.1.19.dist-info → npcsh-1.1.20.dist-info}/entry_points.txt +4 -1
- npcsh/npc_team/jinxs/bin/team_tui.jinx +0 -327
- npcsh/npc_team/jinxs/lib/utils/jinxs.jinx +0 -331
- npcsh-1.1.19.data/data/npcsh/npc_team/jinxs.jinx +0 -331
- npcsh-1.1.19.data/data/npcsh/npc_team/nql.jinx +0 -141
- npcsh-1.1.19.data/data/npcsh/npc_team/team_tui.jinx +0 -327
- {npcsh-1.1.19.data → npcsh-1.1.20.data}/data/npcsh/npc_team/add_tab.jinx +0 -0
- {npcsh-1.1.19.data → npcsh-1.1.20.data}/data/npcsh/npc_team/alicanto.npc +0 -0
- {npcsh-1.1.19.data → npcsh-1.1.20.data}/data/npcsh/npc_team/alicanto.png +0 -0
- {npcsh-1.1.19.data → npcsh-1.1.20.data}/data/npcsh/npc_team/benchmark.jinx +0 -0
- {npcsh-1.1.19.data → npcsh-1.1.20.data}/data/npcsh/npc_team/browser_action.jinx +0 -0
- {npcsh-1.1.19.data → npcsh-1.1.20.data}/data/npcsh/npc_team/browser_screenshot.jinx +0 -0
- {npcsh-1.1.19.data → npcsh-1.1.20.data}/data/npcsh/npc_team/build.jinx +0 -0
- {npcsh-1.1.19.data → npcsh-1.1.20.data}/data/npcsh/npc_team/chat.jinx +0 -0
- {npcsh-1.1.19.data → npcsh-1.1.20.data}/data/npcsh/npc_team/click.jinx +0 -0
- {npcsh-1.1.19.data → npcsh-1.1.20.data}/data/npcsh/npc_team/close_browser.jinx +0 -0
- {npcsh-1.1.19.data → npcsh-1.1.20.data}/data/npcsh/npc_team/close_pane.jinx +0 -0
- {npcsh-1.1.19.data → npcsh-1.1.20.data}/data/npcsh/npc_team/close_tab.jinx +0 -0
- {npcsh-1.1.19.data → npcsh-1.1.20.data}/data/npcsh/npc_team/cmd.jinx +0 -0
- {npcsh-1.1.19.data → npcsh-1.1.20.data}/data/npcsh/npc_team/compile.jinx +0 -0
- {npcsh-1.1.19.data → npcsh-1.1.20.data}/data/npcsh/npc_team/compress.jinx +0 -0
- {npcsh-1.1.19.data → npcsh-1.1.20.data}/data/npcsh/npc_team/confirm.jinx +0 -0
- {npcsh-1.1.19.data → npcsh-1.1.20.data}/data/npcsh/npc_team/convene.jinx +0 -0
- {npcsh-1.1.19.data → npcsh-1.1.20.data}/data/npcsh/npc_team/corca.npc +0 -0
- {npcsh-1.1.19.data → npcsh-1.1.20.data}/data/npcsh/npc_team/corca.png +0 -0
- {npcsh-1.1.19.data → npcsh-1.1.20.data}/data/npcsh/npc_team/corca_example.png +0 -0
- {npcsh-1.1.19.data → npcsh-1.1.20.data}/data/npcsh/npc_team/delegate.jinx +0 -0
- {npcsh-1.1.19.data → npcsh-1.1.20.data}/data/npcsh/npc_team/edit_file.jinx +0 -0
- {npcsh-1.1.19.data → npcsh-1.1.20.data}/data/npcsh/npc_team/focus_pane.jinx +0 -0
- {npcsh-1.1.19.data → npcsh-1.1.20.data}/data/npcsh/npc_team/frederic.npc +0 -0
- {npcsh-1.1.19.data → npcsh-1.1.20.data}/data/npcsh/npc_team/frederic4.png +0 -0
- {npcsh-1.1.19.data → npcsh-1.1.20.data}/data/npcsh/npc_team/guac.npc +0 -0
- {npcsh-1.1.19.data → npcsh-1.1.20.data}/data/npcsh/npc_team/guac.png +0 -0
- {npcsh-1.1.19.data → npcsh-1.1.20.data}/data/npcsh/npc_team/help.jinx +0 -0
- {npcsh-1.1.19.data → npcsh-1.1.20.data}/data/npcsh/npc_team/incognide.jinx +0 -0
- {npcsh-1.1.19.data → npcsh-1.1.20.data}/data/npcsh/npc_team/init.jinx +0 -0
- {npcsh-1.1.19.data → npcsh-1.1.20.data}/data/npcsh/npc_team/kadiefa.npc +0 -0
- {npcsh-1.1.19.data → npcsh-1.1.20.data}/data/npcsh/npc_team/kadiefa.png +0 -0
- {npcsh-1.1.19.data → npcsh-1.1.20.data}/data/npcsh/npc_team/key_press.jinx +0 -0
- {npcsh-1.1.19.data → npcsh-1.1.20.data}/data/npcsh/npc_team/launch_app.jinx +0 -0
- {npcsh-1.1.19.data → npcsh-1.1.20.data}/data/npcsh/npc_team/list_panes.jinx +0 -0
- {npcsh-1.1.19.data → npcsh-1.1.20.data}/data/npcsh/npc_team/load_file.jinx +0 -0
- {npcsh-1.1.19.data → npcsh-1.1.20.data}/data/npcsh/npc_team/mem_review.jinx +0 -0
- {npcsh-1.1.19.data → npcsh-1.1.20.data}/data/npcsh/npc_team/navigate.jinx +0 -0
- {npcsh-1.1.19.data → npcsh-1.1.20.data}/data/npcsh/npc_team/notify.jinx +0 -0
- {npcsh-1.1.19.data → npcsh-1.1.20.data}/data/npcsh/npc_team/npcsh.ctx +0 -0
- {npcsh-1.1.19.data → npcsh-1.1.20.data}/data/npcsh/npc_team/npcsh_sibiji.png +0 -0
- {npcsh-1.1.19.data → npcsh-1.1.20.data}/data/npcsh/npc_team/open_browser.jinx +0 -0
- {npcsh-1.1.19.data → npcsh-1.1.20.data}/data/npcsh/npc_team/open_pane.jinx +0 -0
- {npcsh-1.1.19.data → npcsh-1.1.20.data}/data/npcsh/npc_team/ots.jinx +0 -0
- {npcsh-1.1.19.data → npcsh-1.1.20.data}/data/npcsh/npc_team/paste.jinx +0 -0
- {npcsh-1.1.19.data → npcsh-1.1.20.data}/data/npcsh/npc_team/plonk.npc +0 -0
- {npcsh-1.1.19.data → npcsh-1.1.20.data}/data/npcsh/npc_team/plonk.png +0 -0
- {npcsh-1.1.19.data → npcsh-1.1.20.data}/data/npcsh/npc_team/plonkjr.npc +0 -0
- {npcsh-1.1.19.data → npcsh-1.1.20.data}/data/npcsh/npc_team/plonkjr.png +0 -0
- {npcsh-1.1.19.data → npcsh-1.1.20.data}/data/npcsh/npc_team/python.jinx +0 -0
- {npcsh-1.1.19.data → npcsh-1.1.20.data}/data/npcsh/npc_team/read_pane.jinx +0 -0
- {npcsh-1.1.19.data → npcsh-1.1.20.data}/data/npcsh/npc_team/roll.jinx +0 -0
- {npcsh-1.1.19.data → npcsh-1.1.20.data}/data/npcsh/npc_team/run_terminal.jinx +0 -0
- {npcsh-1.1.19.data → npcsh-1.1.20.data}/data/npcsh/npc_team/sample.jinx +0 -0
- {npcsh-1.1.19.data → npcsh-1.1.20.data}/data/npcsh/npc_team/screenshot.jinx +0 -0
- {npcsh-1.1.19.data → npcsh-1.1.20.data}/data/npcsh/npc_team/search.jinx +0 -0
- {npcsh-1.1.19.data → npcsh-1.1.20.data}/data/npcsh/npc_team/send_message.jinx +0 -0
- {npcsh-1.1.19.data → npcsh-1.1.20.data}/data/npcsh/npc_team/serve.jinx +0 -0
- {npcsh-1.1.19.data → npcsh-1.1.20.data}/data/npcsh/npc_team/set.jinx +0 -0
- {npcsh-1.1.19.data → npcsh-1.1.20.data}/data/npcsh/npc_team/sh.jinx +0 -0
- {npcsh-1.1.19.data → npcsh-1.1.20.data}/data/npcsh/npc_team/shh.jinx +0 -0
- {npcsh-1.1.19.data → npcsh-1.1.20.data}/data/npcsh/npc_team/sibiji.npc +0 -0
- {npcsh-1.1.19.data → npcsh-1.1.20.data}/data/npcsh/npc_team/sibiji.png +0 -0
- {npcsh-1.1.19.data → npcsh-1.1.20.data}/data/npcsh/npc_team/sleep.jinx +0 -0
- {npcsh-1.1.19.data → npcsh-1.1.20.data}/data/npcsh/npc_team/split_pane.jinx +0 -0
- {npcsh-1.1.19.data → npcsh-1.1.20.data}/data/npcsh/npc_team/spool.png +0 -0
- {npcsh-1.1.19.data → npcsh-1.1.20.data}/data/npcsh/npc_team/sql.jinx +0 -0
- {npcsh-1.1.19.data → npcsh-1.1.20.data}/data/npcsh/npc_team/switch.jinx +0 -0
- {npcsh-1.1.19.data → npcsh-1.1.20.data}/data/npcsh/npc_team/switch_npc.jinx +0 -0
- {npcsh-1.1.19.data → npcsh-1.1.20.data}/data/npcsh/npc_team/switch_tab.jinx +0 -0
- {npcsh-1.1.19.data → npcsh-1.1.20.data}/data/npcsh/npc_team/switches.jinx +0 -0
- {npcsh-1.1.19.data → npcsh-1.1.20.data}/data/npcsh/npc_team/sync.jinx +0 -0
- {npcsh-1.1.19.data → npcsh-1.1.20.data}/data/npcsh/npc_team/teamviz.jinx +0 -0
- {npcsh-1.1.19.data → npcsh-1.1.20.data}/data/npcsh/npc_team/trigger.jinx +0 -0
- {npcsh-1.1.19.data → npcsh-1.1.20.data}/data/npcsh/npc_team/type_text.jinx +0 -0
- {npcsh-1.1.19.data → npcsh-1.1.20.data}/data/npcsh/npc_team/usage.jinx +0 -0
- {npcsh-1.1.19.data → npcsh-1.1.20.data}/data/npcsh/npc_team/verbose.jinx +0 -0
- {npcsh-1.1.19.data → npcsh-1.1.20.data}/data/npcsh/npc_team/vixynt.jinx +0 -0
- {npcsh-1.1.19.data → npcsh-1.1.20.data}/data/npcsh/npc_team/wait.jinx +0 -0
- {npcsh-1.1.19.data → npcsh-1.1.20.data}/data/npcsh/npc_team/write_file.jinx +0 -0
- {npcsh-1.1.19.data → npcsh-1.1.20.data}/data/npcsh/npc_team/yap.jinx +0 -0
- {npcsh-1.1.19.data → npcsh-1.1.20.data}/data/npcsh/npc_team/yap.png +0 -0
- {npcsh-1.1.19.data → npcsh-1.1.20.data}/data/npcsh/npc_team/zen_mode.jinx +0 -0
- {npcsh-1.1.19.dist-info → npcsh-1.1.20.dist-info}/WHEEL +0 -0
- {npcsh-1.1.19.dist-info → npcsh-1.1.20.dist-info}/licenses/LICENSE +0 -0
- {npcsh-1.1.19.dist-info → npcsh-1.1.20.dist-info}/top_level.txt +0 -0
|
@@ -0,0 +1,941 @@
|
|
|
1
|
+
jinx_name: kg
|
|
2
|
+
description: Interactive knowledge graph browser - explore facts, concepts, and links
|
|
3
|
+
interactive: true
|
|
4
|
+
inputs: []
|
|
5
|
+
steps:
|
|
6
|
+
- name: kg_browser
|
|
7
|
+
engine: python
|
|
8
|
+
code: |
|
|
9
|
+
import os
|
|
10
|
+
import sys
|
|
11
|
+
import tty
|
|
12
|
+
import termios
|
|
13
|
+
import select
|
|
14
|
+
|
|
15
|
+
if not sys.stdin.isatty():
|
|
16
|
+
context['output'] = "KG browser requires an interactive terminal."
|
|
17
|
+
|
|
18
|
+
else:
|
|
19
|
+
from sqlalchemy import create_engine, text
|
|
20
|
+
|
|
21
|
+
db_path = os.environ.get('NPCSH_DB_PATH', os.path.expanduser('~/npcsh_history.db'))
|
|
22
|
+
engine = create_engine(f'sqlite:///{db_path}')
|
|
23
|
+
|
|
24
|
+
# ── check tables exist ──────────────────────────────────
|
|
25
|
+
tables_ok = False
|
|
26
|
+
try:
|
|
27
|
+
with engine.connect() as conn:
|
|
28
|
+
conn.execute(text("SELECT 1 FROM kg_facts LIMIT 1"))
|
|
29
|
+
conn.execute(text("SELECT 1 FROM kg_concepts LIMIT 1"))
|
|
30
|
+
conn.execute(text("SELECT 1 FROM kg_links LIMIT 1"))
|
|
31
|
+
tables_ok = True
|
|
32
|
+
except Exception:
|
|
33
|
+
tables_ok = False
|
|
34
|
+
|
|
35
|
+
if not tables_ok:
|
|
36
|
+
context['output'] = "No knowledge graph data found. Use agent mode to build KG."
|
|
37
|
+
|
|
38
|
+
else:
|
|
39
|
+
# ── state ───────────────────────────────────────────
|
|
40
|
+
import math
|
|
41
|
+
|
|
42
|
+
class KGState:
|
|
43
|
+
def __init__(self):
|
|
44
|
+
self.tab = 0
|
|
45
|
+
self.tabs = ['Facts', 'Concepts', 'Links', 'Search', 'Graph']
|
|
46
|
+
self.sel = 0
|
|
47
|
+
self.scroll = 0
|
|
48
|
+
self.detail = False
|
|
49
|
+
self.detail_scroll = 0
|
|
50
|
+
self.status = ""
|
|
51
|
+
|
|
52
|
+
# data
|
|
53
|
+
self.facts = []
|
|
54
|
+
self.concepts = []
|
|
55
|
+
self.links = []
|
|
56
|
+
self.search_results = []
|
|
57
|
+
|
|
58
|
+
# stats
|
|
59
|
+
self.fact_count = 0
|
|
60
|
+
self.concept_count = 0
|
|
61
|
+
self.link_count = 0
|
|
62
|
+
self.max_gen = 0
|
|
63
|
+
|
|
64
|
+
# generation filter
|
|
65
|
+
self.gen_filter = None # None = all
|
|
66
|
+
|
|
67
|
+
# search
|
|
68
|
+
self.search_mode = False
|
|
69
|
+
self.search_buf = ""
|
|
70
|
+
|
|
71
|
+
# detail data cache
|
|
72
|
+
self.detail_lines = []
|
|
73
|
+
|
|
74
|
+
# concept link counts for display
|
|
75
|
+
self.concept_link_counts = {}
|
|
76
|
+
|
|
77
|
+
# graph view
|
|
78
|
+
self.graph_adj = {} # concept -> set of neighbors
|
|
79
|
+
self.graph_center = ''
|
|
80
|
+
self.graph_neighbors = []
|
|
81
|
+
self.graph_sel = 0
|
|
82
|
+
self.graph_history = [] # stack for back navigation
|
|
83
|
+
|
|
84
|
+
ui = KGState()
|
|
85
|
+
|
|
86
|
+
def term_size():
|
|
87
|
+
try:
|
|
88
|
+
s = os.get_terminal_size()
|
|
89
|
+
return s.columns, s.lines
|
|
90
|
+
except Exception:
|
|
91
|
+
return 80, 24
|
|
92
|
+
|
|
93
|
+
# ── database loading ─────────────────────────────────
|
|
94
|
+
def load_stats():
|
|
95
|
+
with engine.connect() as conn:
|
|
96
|
+
r = conn.execute(text("SELECT COUNT(*) FROM kg_facts"))
|
|
97
|
+
ui.fact_count = r.scalar() or 0
|
|
98
|
+
r = conn.execute(text("SELECT COUNT(*) FROM kg_concepts"))
|
|
99
|
+
ui.concept_count = r.scalar() or 0
|
|
100
|
+
r = conn.execute(text("SELECT COUNT(*) FROM kg_links"))
|
|
101
|
+
ui.link_count = r.scalar() or 0
|
|
102
|
+
r = conn.execute(text("SELECT COALESCE(MAX(generation), 0) FROM kg_facts"))
|
|
103
|
+
ui.max_gen = r.scalar() or 0
|
|
104
|
+
|
|
105
|
+
def load_facts():
|
|
106
|
+
ui.facts = []
|
|
107
|
+
with engine.connect() as conn:
|
|
108
|
+
if ui.gen_filter is not None:
|
|
109
|
+
r = conn.execute(text(
|
|
110
|
+
"SELECT statement, source_text, type, generation, origin "
|
|
111
|
+
"FROM kg_facts WHERE generation = :gen ORDER BY rowid DESC"
|
|
112
|
+
), {"gen": ui.gen_filter})
|
|
113
|
+
else:
|
|
114
|
+
r = conn.execute(text(
|
|
115
|
+
"SELECT statement, source_text, type, generation, origin "
|
|
116
|
+
"FROM kg_facts ORDER BY rowid DESC"
|
|
117
|
+
))
|
|
118
|
+
for row in r:
|
|
119
|
+
ui.facts.append({
|
|
120
|
+
'statement': row.statement or '',
|
|
121
|
+
'source_text': row.source_text or '',
|
|
122
|
+
'type': row.type or '',
|
|
123
|
+
'generation': row.generation if row.generation is not None else 0,
|
|
124
|
+
'origin': row.origin or 'organic',
|
|
125
|
+
})
|
|
126
|
+
|
|
127
|
+
def load_concepts():
|
|
128
|
+
ui.concepts = []
|
|
129
|
+
ui.concept_link_counts = {}
|
|
130
|
+
with engine.connect() as conn:
|
|
131
|
+
if ui.gen_filter is not None:
|
|
132
|
+
r = conn.execute(text(
|
|
133
|
+
"SELECT name, generation, origin "
|
|
134
|
+
"FROM kg_concepts WHERE generation = :gen ORDER BY name"
|
|
135
|
+
), {"gen": ui.gen_filter})
|
|
136
|
+
else:
|
|
137
|
+
r = conn.execute(text(
|
|
138
|
+
"SELECT name, generation, origin FROM kg_concepts ORDER BY name"
|
|
139
|
+
))
|
|
140
|
+
for row in r:
|
|
141
|
+
ui.concepts.append({
|
|
142
|
+
'name': row.name or '',
|
|
143
|
+
'generation': row.generation if row.generation is not None else 0,
|
|
144
|
+
'origin': row.origin or 'organic',
|
|
145
|
+
})
|
|
146
|
+
# count linked facts per concept
|
|
147
|
+
r2 = conn.execute(text(
|
|
148
|
+
"SELECT target, COUNT(*) as cnt FROM kg_links "
|
|
149
|
+
"WHERE type = 'fact_to_concept' GROUP BY target"
|
|
150
|
+
))
|
|
151
|
+
for row in r2:
|
|
152
|
+
ui.concept_link_counts[row.target] = row.cnt
|
|
153
|
+
|
|
154
|
+
def load_links():
|
|
155
|
+
ui.links = []
|
|
156
|
+
with engine.connect() as conn:
|
|
157
|
+
r = conn.execute(text(
|
|
158
|
+
"SELECT source, target, type FROM kg_links ORDER BY rowid DESC"
|
|
159
|
+
))
|
|
160
|
+
for row in r:
|
|
161
|
+
ui.links.append({
|
|
162
|
+
'source': row.source or '',
|
|
163
|
+
'target': row.target or '',
|
|
164
|
+
'type': row.type or '',
|
|
165
|
+
})
|
|
166
|
+
|
|
167
|
+
def do_search(query):
|
|
168
|
+
ui.search_results = []
|
|
169
|
+
q = f"%{query}%"
|
|
170
|
+
with engine.connect() as conn:
|
|
171
|
+
r = conn.execute(text(
|
|
172
|
+
"SELECT statement, source_text, type, generation, origin "
|
|
173
|
+
"FROM kg_facts WHERE statement LIKE :q ORDER BY rowid DESC"
|
|
174
|
+
), {"q": q})
|
|
175
|
+
for row in r:
|
|
176
|
+
ui.search_results.append({
|
|
177
|
+
'kind': 'fact',
|
|
178
|
+
'text': row.statement or '',
|
|
179
|
+
'source_text': row.source_text or '',
|
|
180
|
+
'type': row.type or '',
|
|
181
|
+
'generation': row.generation if row.generation is not None else 0,
|
|
182
|
+
'origin': row.origin or 'organic',
|
|
183
|
+
})
|
|
184
|
+
r2 = conn.execute(text(
|
|
185
|
+
"SELECT name, generation, origin "
|
|
186
|
+
"FROM kg_concepts WHERE name LIKE :q ORDER BY name"
|
|
187
|
+
), {"q": q})
|
|
188
|
+
for row in r2:
|
|
189
|
+
ui.search_results.append({
|
|
190
|
+
'kind': 'concept',
|
|
191
|
+
'text': row.name or '',
|
|
192
|
+
'source_text': '',
|
|
193
|
+
'type': '',
|
|
194
|
+
'generation': row.generation if row.generation is not None else 0,
|
|
195
|
+
'origin': row.origin or 'organic',
|
|
196
|
+
})
|
|
197
|
+
|
|
198
|
+
def load_fact_detail(idx):
|
|
199
|
+
"""Build detail lines for a fact."""
|
|
200
|
+
if idx >= len(ui.facts):
|
|
201
|
+
ui.detail_lines = ["(no data)"]
|
|
202
|
+
return
|
|
203
|
+
fact = ui.facts[idx]
|
|
204
|
+
lines = []
|
|
205
|
+
lines.append(("\033[1mFact:\033[0m", ""))
|
|
206
|
+
# word-wrap statement
|
|
207
|
+
W, _ = term_size()
|
|
208
|
+
wrap_w = max(20, W - 6)
|
|
209
|
+
stmt = fact['statement']
|
|
210
|
+
while len(stmt) > wrap_w:
|
|
211
|
+
lines.append((" " + stmt[:wrap_w], ""))
|
|
212
|
+
stmt = stmt[wrap_w:]
|
|
213
|
+
if stmt:
|
|
214
|
+
lines.append((" " + stmt, ""))
|
|
215
|
+
lines.append(("", ""))
|
|
216
|
+
if fact['source_text']:
|
|
217
|
+
lines.append(("\033[1mSource text:\033[0m", ""))
|
|
218
|
+
st = fact['source_text']
|
|
219
|
+
while len(st) > wrap_w:
|
|
220
|
+
lines.append((" " + st[:wrap_w], ""))
|
|
221
|
+
st = st[wrap_w:]
|
|
222
|
+
if st:
|
|
223
|
+
lines.append((" " + st, ""))
|
|
224
|
+
lines.append(("", ""))
|
|
225
|
+
lines.append((f"\033[1mType:\033[0m {fact['type']}", ""))
|
|
226
|
+
lines.append((f"\033[1mGeneration:\033[0m {fact['generation']}", ""))
|
|
227
|
+
lines.append((f"\033[1mOrigin:\033[0m {fact['origin']}", ""))
|
|
228
|
+
lines.append(("", ""))
|
|
229
|
+
# linked concepts
|
|
230
|
+
lines.append(("\033[1mLinked Concepts:\033[0m", ""))
|
|
231
|
+
with engine.connect() as conn:
|
|
232
|
+
r = conn.execute(text(
|
|
233
|
+
"SELECT target FROM kg_links "
|
|
234
|
+
"WHERE source = :src AND type = 'fact_to_concept'"
|
|
235
|
+
), {"src": fact['statement']})
|
|
236
|
+
found = False
|
|
237
|
+
for row in r:
|
|
238
|
+
lines.append((" \033[36m" + (row.target or '') + "\033[0m", ""))
|
|
239
|
+
found = True
|
|
240
|
+
if not found:
|
|
241
|
+
lines.append((" \033[90m(none)\033[0m", ""))
|
|
242
|
+
ui.detail_lines = lines
|
|
243
|
+
|
|
244
|
+
def load_concept_detail(idx):
|
|
245
|
+
"""Build detail lines for a concept."""
|
|
246
|
+
if idx >= len(ui.concepts):
|
|
247
|
+
ui.detail_lines = ["(no data)"]
|
|
248
|
+
return
|
|
249
|
+
concept = ui.concepts[idx]
|
|
250
|
+
lines = []
|
|
251
|
+
lines.append(("\033[1mConcept:\033[0m \033[36m" + concept['name'] + "\033[0m", ""))
|
|
252
|
+
lines.append((f"\033[1mGeneration:\033[0m {concept['generation']}", ""))
|
|
253
|
+
lines.append((f"\033[1mOrigin:\033[0m {concept['origin']}", ""))
|
|
254
|
+
lines.append(("", ""))
|
|
255
|
+
# linked facts
|
|
256
|
+
lines.append(("\033[1mLinked Facts:\033[0m", ""))
|
|
257
|
+
W, _ = term_size()
|
|
258
|
+
wrap_w = max(20, W - 6)
|
|
259
|
+
with engine.connect() as conn:
|
|
260
|
+
r = conn.execute(text(
|
|
261
|
+
"SELECT source FROM kg_links "
|
|
262
|
+
"WHERE target = :tgt AND type = 'fact_to_concept'"
|
|
263
|
+
), {"tgt": concept['name']})
|
|
264
|
+
found = False
|
|
265
|
+
for row in r:
|
|
266
|
+
s = row.source or ''
|
|
267
|
+
if len(s) > wrap_w:
|
|
268
|
+
s = s[:wrap_w - 3] + '...'
|
|
269
|
+
lines.append((" " + s, ""))
|
|
270
|
+
found = True
|
|
271
|
+
if not found:
|
|
272
|
+
lines.append((" \033[90m(none)\033[0m", ""))
|
|
273
|
+
lines.append(("", ""))
|
|
274
|
+
# linked concepts
|
|
275
|
+
lines.append(("\033[1mLinked Concepts:\033[0m", ""))
|
|
276
|
+
with engine.connect() as conn:
|
|
277
|
+
r = conn.execute(text(
|
|
278
|
+
"SELECT target FROM kg_links "
|
|
279
|
+
"WHERE source = :name AND type = 'concept_to_concept' "
|
|
280
|
+
"UNION "
|
|
281
|
+
"SELECT source FROM kg_links "
|
|
282
|
+
"WHERE target = :name AND type = 'concept_to_concept'"
|
|
283
|
+
), {"name": concept['name']})
|
|
284
|
+
found2 = False
|
|
285
|
+
for row in r:
|
|
286
|
+
lines.append((" \033[36m" + (row[0] or '') + "\033[0m", ""))
|
|
287
|
+
found2 = True
|
|
288
|
+
if not found2:
|
|
289
|
+
lines.append((" \033[90m(none)\033[0m", ""))
|
|
290
|
+
ui.detail_lines = lines
|
|
291
|
+
|
|
292
|
+
def load_search_detail(idx):
|
|
293
|
+
"""Build detail for a search result item."""
|
|
294
|
+
if idx >= len(ui.search_results):
|
|
295
|
+
ui.detail_lines = ["(no data)"]
|
|
296
|
+
return
|
|
297
|
+
item = ui.search_results[idx]
|
|
298
|
+
if item['kind'] == 'fact':
|
|
299
|
+
# find it in full facts list by statement match and delegate
|
|
300
|
+
for fi, f in enumerate(ui.facts):
|
|
301
|
+
if f['statement'] == item['text']:
|
|
302
|
+
load_fact_detail(fi)
|
|
303
|
+
return
|
|
304
|
+
# fallback: build inline
|
|
305
|
+
ui.detail_lines = [
|
|
306
|
+
("\033[1mFact:\033[0m " + item['text'], ""),
|
|
307
|
+
(f"\033[1mOrigin:\033[0m {item['origin']}", ""),
|
|
308
|
+
(f"\033[1mGeneration:\033[0m {item['generation']}", ""),
|
|
309
|
+
]
|
|
310
|
+
else:
|
|
311
|
+
for ci, c in enumerate(ui.concepts):
|
|
312
|
+
if c['name'] == item['text']:
|
|
313
|
+
load_concept_detail(ci)
|
|
314
|
+
return
|
|
315
|
+
ui.detail_lines = [
|
|
316
|
+
("\033[1mConcept:\033[0m \033[36m" + item['text'] + "\033[0m", ""),
|
|
317
|
+
(f"\033[1mOrigin:\033[0m {item['origin']}", ""),
|
|
318
|
+
(f"\033[1mGeneration:\033[0m {item['generation']}", ""),
|
|
319
|
+
]
|
|
320
|
+
|
|
321
|
+
def load_all_data():
|
|
322
|
+
load_stats()
|
|
323
|
+
load_facts()
|
|
324
|
+
load_concepts()
|
|
325
|
+
load_links()
|
|
326
|
+
load_graph_data()
|
|
327
|
+
|
|
328
|
+
def load_graph_data():
|
|
329
|
+
"""Load concept-to-concept adjacency for graph view."""
|
|
330
|
+
ui.graph_adj = {}
|
|
331
|
+
with engine.connect() as conn:
|
|
332
|
+
r = conn.execute(text(
|
|
333
|
+
"SELECT source, target FROM kg_links "
|
|
334
|
+
"WHERE type = 'concept_to_concept'"
|
|
335
|
+
))
|
|
336
|
+
for row in r:
|
|
337
|
+
src = row.source or ''
|
|
338
|
+
tgt = row.target or ''
|
|
339
|
+
if src not in ui.graph_adj:
|
|
340
|
+
ui.graph_adj[src] = set()
|
|
341
|
+
if tgt not in ui.graph_adj:
|
|
342
|
+
ui.graph_adj[tgt] = set()
|
|
343
|
+
ui.graph_adj[src].add(tgt)
|
|
344
|
+
ui.graph_adj[tgt].add(src)
|
|
345
|
+
# Also add concepts with fact links but no concept links
|
|
346
|
+
for c in ui.concepts:
|
|
347
|
+
if c['name'] not in ui.graph_adj:
|
|
348
|
+
ui.graph_adj[c['name']] = set()
|
|
349
|
+
# Pick the most connected concept as initial center
|
|
350
|
+
if ui.graph_adj:
|
|
351
|
+
ui.graph_center = max(
|
|
352
|
+
ui.graph_adj.keys(),
|
|
353
|
+
key=lambda k: len(ui.graph_adj[k])
|
|
354
|
+
)
|
|
355
|
+
else:
|
|
356
|
+
ui.graph_center = ui.concepts[0]['name'] if ui.concepts else ''
|
|
357
|
+
ui.graph_history = []
|
|
358
|
+
update_graph_neighbors()
|
|
359
|
+
|
|
360
|
+
def update_graph_neighbors():
|
|
361
|
+
"""Update neighbors list for current graph center."""
|
|
362
|
+
if ui.graph_center in ui.graph_adj:
|
|
363
|
+
ui.graph_neighbors = sorted(ui.graph_adj[ui.graph_center])
|
|
364
|
+
else:
|
|
365
|
+
ui.graph_neighbors = []
|
|
366
|
+
ui.graph_sel = 0
|
|
367
|
+
|
|
368
|
+
# ── rendering ───────────────────────────────────────
|
|
369
|
+
def wline(row, txt):
|
|
370
|
+
return f"\033[{row};1H\033[K{txt}"
|
|
371
|
+
|
|
372
|
+
def render():
|
|
373
|
+
W, H = term_size()
|
|
374
|
+
out = []
|
|
375
|
+
out.append("\033[H")
|
|
376
|
+
|
|
377
|
+
# ── header ──
|
|
378
|
+
hdr = " Knowledge Graph "
|
|
379
|
+
pad = '=' * W
|
|
380
|
+
out.append(wline(1, f"\033[44;37;1m{pad}\033[0m"))
|
|
381
|
+
out.append(f"\033[1;{max(1,(W - len(hdr))//2)}H\033[44;37;1m{hdr}\033[0m")
|
|
382
|
+
|
|
383
|
+
# ── tabs ──
|
|
384
|
+
tb = ""
|
|
385
|
+
for i, t in enumerate(ui.tabs):
|
|
386
|
+
if i == ui.tab:
|
|
387
|
+
tb += f"\033[7;1m [{t}] \033[0m"
|
|
388
|
+
else:
|
|
389
|
+
tb += f" {t} "
|
|
390
|
+
out.append(wline(2, f" {tb}"))
|
|
391
|
+
out.append(wline(3, f"\033[90m{'─' * W}\033[0m"))
|
|
392
|
+
|
|
393
|
+
# ── stats line ──
|
|
394
|
+
gf = f"Gen: {ui.gen_filter}" if ui.gen_filter is not None else "Gen: all"
|
|
395
|
+
stats_line = (
|
|
396
|
+
f" {gf} Facts: {ui.fact_count} "
|
|
397
|
+
f"Concepts: {ui.concept_count} Links: {ui.link_count}"
|
|
398
|
+
)
|
|
399
|
+
out.append(wline(4, f"\033[90m{stats_line}\033[0m"))
|
|
400
|
+
out.append(wline(5, f"\033[90m{'─' * W}\033[0m"))
|
|
401
|
+
|
|
402
|
+
# ── body ──
|
|
403
|
+
body_start = 6
|
|
404
|
+
body_end = H - 3
|
|
405
|
+
body_h = body_end - body_start + 1
|
|
406
|
+
if body_h < 1:
|
|
407
|
+
body_h = 1
|
|
408
|
+
|
|
409
|
+
if ui.search_mode:
|
|
410
|
+
render_search_input(out, W, body_start, body_h)
|
|
411
|
+
elif ui.detail:
|
|
412
|
+
render_detail(out, W, body_start, body_h)
|
|
413
|
+
elif ui.tab == 0:
|
|
414
|
+
render_facts(out, W, body_start, body_h)
|
|
415
|
+
elif ui.tab == 1:
|
|
416
|
+
render_concepts(out, W, body_start, body_h)
|
|
417
|
+
elif ui.tab == 2:
|
|
418
|
+
render_links_tab(out, W, body_start, body_h)
|
|
419
|
+
elif ui.tab == 3:
|
|
420
|
+
render_search_results(out, W, body_start, body_h)
|
|
421
|
+
elif ui.tab == 4:
|
|
422
|
+
render_graph(out, W, body_start, body_h)
|
|
423
|
+
|
|
424
|
+
# ── separator ──
|
|
425
|
+
out.append(wline(H - 2, f"\033[90m{'─' * W}\033[0m"))
|
|
426
|
+
|
|
427
|
+
# ── status ──
|
|
428
|
+
if ui.status:
|
|
429
|
+
out.append(wline(H - 1, f" \033[33m{ui.status[:W-2]}\033[0m"))
|
|
430
|
+
else:
|
|
431
|
+
out.append(wline(H - 1, ""))
|
|
432
|
+
|
|
433
|
+
# ── footer ──
|
|
434
|
+
if ui.search_mode:
|
|
435
|
+
foot = " Type query, [Enter] Search [Esc] Cancel"
|
|
436
|
+
elif ui.detail:
|
|
437
|
+
foot = " [j/k] Scroll [q/Esc] Back"
|
|
438
|
+
elif ui.tab == 4:
|
|
439
|
+
foot = " [Tab] Switch [j/k] Select [Enter] Center [Backspace] Back [q] Quit"
|
|
440
|
+
else:
|
|
441
|
+
foot = " [Tab] Switch [j/k] Nav [Enter] Detail [/] Search [g] Gen [q] Quit"
|
|
442
|
+
out.append(wline(H, f"\033[44;37m{foot[:W].ljust(W)}\033[0m"))
|
|
443
|
+
|
|
444
|
+
sys.stdout.write(''.join(out))
|
|
445
|
+
sys.stdout.flush()
|
|
446
|
+
|
|
447
|
+
# ── tab renderers ───────────────────────────────────
|
|
448
|
+
|
|
449
|
+
def render_facts(out, W, start, body_h):
|
|
450
|
+
vis = ui.facts[ui.scroll:ui.scroll + body_h]
|
|
451
|
+
for r in range(body_h):
|
|
452
|
+
row = start + r
|
|
453
|
+
i = r + ui.scroll
|
|
454
|
+
if r >= len(vis):
|
|
455
|
+
out.append(wline(row, ""))
|
|
456
|
+
continue
|
|
457
|
+
f = vis[r]
|
|
458
|
+
stmt = f['statement'].replace('\n', ' ')
|
|
459
|
+
origin_tag = f"[{f['origin']}]"
|
|
460
|
+
gen_tag = f"gen:{f['generation']}"
|
|
461
|
+
tag = f"\033[90m{origin_tag} {gen_tag}\033[0m"
|
|
462
|
+
max_stmt = W - len(origin_tag) - len(gen_tag) - 10
|
|
463
|
+
if max_stmt < 10:
|
|
464
|
+
max_stmt = 10
|
|
465
|
+
if len(stmt) > max_stmt:
|
|
466
|
+
stmt = stmt[:max_stmt - 3] + '...'
|
|
467
|
+
if i == ui.sel:
|
|
468
|
+
line = f" > {stmt}"
|
|
469
|
+
# pad to W then add tag
|
|
470
|
+
padded = line[:W-len(origin_tag)-len(gen_tag)-4].ljust(W-len(origin_tag)-len(gen_tag)-4)
|
|
471
|
+
out.append(wline(row, f"\033[7m{padded} {origin_tag} {gen_tag}\033[0m"))
|
|
472
|
+
else:
|
|
473
|
+
out.append(wline(row, f" {stmt} {tag}"))
|
|
474
|
+
if not ui.facts:
|
|
475
|
+
out.append(wline(start, " \033[90mNo facts found.\033[0m"))
|
|
476
|
+
for r in range(1, body_h):
|
|
477
|
+
out.append(wline(start + r, ""))
|
|
478
|
+
|
|
479
|
+
def render_concepts(out, W, start, body_h):
|
|
480
|
+
vis = ui.concepts[ui.scroll:ui.scroll + body_h]
|
|
481
|
+
for r in range(body_h):
|
|
482
|
+
row = start + r
|
|
483
|
+
i = r + ui.scroll
|
|
484
|
+
if r >= len(vis):
|
|
485
|
+
out.append(wline(row, ""))
|
|
486
|
+
continue
|
|
487
|
+
c = vis[r]
|
|
488
|
+
name = c['name']
|
|
489
|
+
lc = ui.concept_link_counts.get(name, 0)
|
|
490
|
+
origin_tag = f"[{c['origin']}]"
|
|
491
|
+
gen_tag = f"gen:{c['generation']}"
|
|
492
|
+
info = f"\033[90m({lc} facts) {origin_tag} {gen_tag}\033[0m"
|
|
493
|
+
max_name = W - 30
|
|
494
|
+
if max_name < 10:
|
|
495
|
+
max_name = 10
|
|
496
|
+
if len(name) > max_name:
|
|
497
|
+
name = name[:max_name - 3] + '...'
|
|
498
|
+
if i == ui.sel:
|
|
499
|
+
out.append(wline(row, f"\033[7m > {name:<{max_name}} ({lc} facts) {origin_tag} {gen_tag}\033[0m"))
|
|
500
|
+
else:
|
|
501
|
+
out.append(wline(row, f" \033[36m{name:<{max_name}}\033[0m {info}"))
|
|
502
|
+
if not ui.concepts:
|
|
503
|
+
out.append(wline(start, " \033[90mNo concepts found.\033[0m"))
|
|
504
|
+
for r in range(1, body_h):
|
|
505
|
+
out.append(wline(start + r, ""))
|
|
506
|
+
|
|
507
|
+
def render_links_tab(out, W, start, body_h):
|
|
508
|
+
vis = ui.links[ui.scroll:ui.scroll + body_h]
|
|
509
|
+
for r in range(body_h):
|
|
510
|
+
row = start + r
|
|
511
|
+
i = r + ui.scroll
|
|
512
|
+
if r >= len(vis):
|
|
513
|
+
out.append(wline(row, ""))
|
|
514
|
+
continue
|
|
515
|
+
lnk = vis[r]
|
|
516
|
+
src = lnk['source'].replace('\n', ' ')
|
|
517
|
+
tgt = lnk['target'].replace('\n', ' ')
|
|
518
|
+
lt = lnk['type']
|
|
519
|
+
max_w = (W - 12) // 2
|
|
520
|
+
if max_w < 10:
|
|
521
|
+
max_w = 10
|
|
522
|
+
if len(src) > max_w:
|
|
523
|
+
src = src[:max_w - 3] + '...'
|
|
524
|
+
if len(tgt) > max_w:
|
|
525
|
+
tgt = tgt[:max_w - 3] + '...'
|
|
526
|
+
arrow = f"\033[33m -> \033[0m"
|
|
527
|
+
type_tag = f"\033[90m[{lt}]\033[0m"
|
|
528
|
+
if i == ui.sel:
|
|
529
|
+
out.append(wline(row, f"\033[7m > {src} -> {tgt} [{lt}]\033[0m"))
|
|
530
|
+
else:
|
|
531
|
+
out.append(wline(row, f" {src}{arrow}{tgt} {type_tag}"))
|
|
532
|
+
if not ui.links:
|
|
533
|
+
out.append(wline(start, " \033[90mNo links found.\033[0m"))
|
|
534
|
+
for r in range(1, body_h):
|
|
535
|
+
out.append(wline(start + r, ""))
|
|
536
|
+
|
|
537
|
+
def render_search_results(out, W, start, body_h):
|
|
538
|
+
items = ui.search_results
|
|
539
|
+
vis = items[ui.scroll:ui.scroll + body_h]
|
|
540
|
+
for r in range(body_h):
|
|
541
|
+
row = start + r
|
|
542
|
+
i = r + ui.scroll
|
|
543
|
+
if r >= len(vis):
|
|
544
|
+
out.append(wline(row, ""))
|
|
545
|
+
continue
|
|
546
|
+
item = vis[r]
|
|
547
|
+
kind_tag = f"\033[90m[{item['kind']}]\033[0m"
|
|
548
|
+
txt = item['text'].replace('\n', ' ')
|
|
549
|
+
origin_tag = f"[{item['origin']}]"
|
|
550
|
+
gen_tag = f"gen:{item['generation']}"
|
|
551
|
+
max_t = W - 30
|
|
552
|
+
if max_t < 10:
|
|
553
|
+
max_t = 10
|
|
554
|
+
if len(txt) > max_t:
|
|
555
|
+
txt = txt[:max_t - 3] + '...'
|
|
556
|
+
if item['kind'] == 'concept':
|
|
557
|
+
txt = f"\033[36m{txt}\033[0m"
|
|
558
|
+
if i == ui.sel:
|
|
559
|
+
raw_txt = item['text'].replace('\n', ' ')
|
|
560
|
+
if len(raw_txt) > max_t:
|
|
561
|
+
raw_txt = raw_txt[:max_t - 3] + '...'
|
|
562
|
+
out.append(wline(row, f"\033[7m > [{item['kind']}] {raw_txt} {origin_tag} {gen_tag}\033[0m"))
|
|
563
|
+
else:
|
|
564
|
+
out.append(wline(row, f" {kind_tag} {txt} \033[90m{origin_tag} {gen_tag}\033[0m"))
|
|
565
|
+
if not items:
|
|
566
|
+
out.append(wline(start, " \033[90mNo search results. Press / to search.\033[0m"))
|
|
567
|
+
for r in range(1, body_h):
|
|
568
|
+
out.append(wline(start + r, ""))
|
|
569
|
+
|
|
570
|
+
def render_search_input(out, W, start, body_h):
|
|
571
|
+
out.append(wline(start, f" \033[1mSearch:\033[0m {ui.search_buf}\033[7m \033[0m"))
|
|
572
|
+
for r in range(1, body_h):
|
|
573
|
+
out.append(wline(start + r, ""))
|
|
574
|
+
|
|
575
|
+
def render_detail(out, W, start, body_h):
|
|
576
|
+
lines = ui.detail_lines
|
|
577
|
+
vis = lines[ui.detail_scroll:ui.detail_scroll + body_h]
|
|
578
|
+
for r in range(body_h):
|
|
579
|
+
row = start + r
|
|
580
|
+
if r >= len(vis):
|
|
581
|
+
out.append(wline(row, ""))
|
|
582
|
+
continue
|
|
583
|
+
line_data = vis[r]
|
|
584
|
+
if isinstance(line_data, tuple):
|
|
585
|
+
txt = line_data[0]
|
|
586
|
+
else:
|
|
587
|
+
txt = str(line_data)
|
|
588
|
+
out.append(wline(row, f" {txt}"))
|
|
589
|
+
|
|
590
|
+
# ── graph rendering ─────────────────────────────────
|
|
591
|
+
|
|
592
|
+
def render_graph(out, W, start, body_h):
|
|
593
|
+
"""Render a center+spoke graph of concepts."""
|
|
594
|
+
if not ui.graph_center:
|
|
595
|
+
out.append(wline(start, " \033[90mNo concepts available for graph.\033[0m"))
|
|
596
|
+
for r in range(1, body_h):
|
|
597
|
+
out.append(wline(start + r, ""))
|
|
598
|
+
return
|
|
599
|
+
|
|
600
|
+
# Build a character canvas
|
|
601
|
+
canvas = [[' '] * W for _ in range(body_h)]
|
|
602
|
+
# Color map: parallel array of ANSI codes per cell (empty = default)
|
|
603
|
+
colors = [['' for _ in range(W)] for _ in range(body_h)]
|
|
604
|
+
|
|
605
|
+
cx = W // 2
|
|
606
|
+
cy = body_h // 2
|
|
607
|
+
|
|
608
|
+
# Draw center node
|
|
609
|
+
clabel = ui.graph_center
|
|
610
|
+
if len(clabel) > W // 3:
|
|
611
|
+
clabel = clabel[:W // 3 - 2] + '..'
|
|
612
|
+
ctext = f"\u2503 {clabel} \u2503"
|
|
613
|
+
ctop = "\u250f" + "\u2501" * (len(ctext) - 2) + "\u2513"
|
|
614
|
+
cbot = "\u2517" + "\u2501" * (len(ctext) - 2) + "\u251b"
|
|
615
|
+
cx0 = cx - len(ctext) // 2
|
|
616
|
+
|
|
617
|
+
# Place center box (3 rows)
|
|
618
|
+
for i, ch in enumerate(ctop):
|
|
619
|
+
if 0 <= cx0 + i < W and 0 <= cy - 1 < body_h:
|
|
620
|
+
canvas[cy - 1][cx0 + i] = ch
|
|
621
|
+
colors[cy - 1][cx0 + i] = '\033[1;33m'
|
|
622
|
+
for i, ch in enumerate(ctext):
|
|
623
|
+
if 0 <= cx0 + i < W and 0 <= cy < body_h:
|
|
624
|
+
canvas[cy][cx0 + i] = ch
|
|
625
|
+
colors[cy][cx0 + i] = '\033[1;33m'
|
|
626
|
+
for i, ch in enumerate(cbot):
|
|
627
|
+
if 0 <= cx0 + i < W and 0 <= cy + 1 < body_h:
|
|
628
|
+
canvas[cy + 1][cx0 + i] = ch
|
|
629
|
+
colors[cy + 1][cx0 + i] = '\033[1;33m'
|
|
630
|
+
|
|
631
|
+
# Center box bounds (for avoiding overwrite)
|
|
632
|
+
cbox_left = cx0
|
|
633
|
+
cbox_right = cx0 + len(ctext)
|
|
634
|
+
cbox_top = cy - 1
|
|
635
|
+
cbox_bot = cy + 1
|
|
636
|
+
|
|
637
|
+
neighbors = ui.graph_neighbors
|
|
638
|
+
n = len(neighbors)
|
|
639
|
+
|
|
640
|
+
if n > 0:
|
|
641
|
+
# Calculate radius based on terminal size
|
|
642
|
+
rx = min(W // 3, 30)
|
|
643
|
+
ry = min(body_h // 3, max(3, body_h // 4))
|
|
644
|
+
|
|
645
|
+
for idx, nbr in enumerate(neighbors):
|
|
646
|
+
angle = (2 * math.pi * idx) / n - math.pi / 2
|
|
647
|
+
nx = cx + int(rx * math.cos(angle))
|
|
648
|
+
ny = cy + int(ry * math.sin(angle))
|
|
649
|
+
|
|
650
|
+
# Clamp neighbor position
|
|
651
|
+
nlabel = nbr
|
|
652
|
+
if len(nlabel) > 18:
|
|
653
|
+
nlabel = nlabel[:15] + '..'
|
|
654
|
+
if idx == ui.graph_sel:
|
|
655
|
+
ntext = f"[{nlabel}]"
|
|
656
|
+
ncolor = '\033[7;36m'
|
|
657
|
+
else:
|
|
658
|
+
ntext = f"({nlabel})"
|
|
659
|
+
ncolor = '\033[36m'
|
|
660
|
+
|
|
661
|
+
nl = len(ntext)
|
|
662
|
+
nlx = max(0, min(W - nl, nx - nl // 2))
|
|
663
|
+
nly = max(0, min(body_h - 1, ny))
|
|
664
|
+
|
|
665
|
+
# Place neighbor label
|
|
666
|
+
for i, ch in enumerate(ntext):
|
|
667
|
+
if 0 <= nlx + i < W:
|
|
668
|
+
canvas[nly][nlx + i] = ch
|
|
669
|
+
colors[nly][nlx + i] = ncolor
|
|
670
|
+
|
|
671
|
+
# Draw edge from center to neighbor
|
|
672
|
+
edge_x1, edge_y1 = cx, cy
|
|
673
|
+
edge_x2, edge_y2 = nlx + nl // 2, nly
|
|
674
|
+
draw_edge(canvas, colors, W, body_h,
|
|
675
|
+
edge_x1, edge_y1, edge_x2, edge_y2,
|
|
676
|
+
cbox_left, cbox_right, cbox_top, cbox_bot)
|
|
677
|
+
|
|
678
|
+
# Fact count for center
|
|
679
|
+
fc = ui.concept_link_counts.get(ui.graph_center, 0)
|
|
680
|
+
info = f"{fc} linked facts, {n} connected concepts"
|
|
681
|
+
info_x = max(0, cx - len(info) // 2)
|
|
682
|
+
info_y = min(body_h - 1, cy + 3)
|
|
683
|
+
for i, ch in enumerate(info):
|
|
684
|
+
if 0 <= info_x + i < W and canvas[info_y][info_x + i] == ' ':
|
|
685
|
+
canvas[info_y][info_x + i] = ch
|
|
686
|
+
colors[info_y][info_x + i] = '\033[90m'
|
|
687
|
+
|
|
688
|
+
# Render canvas to output
|
|
689
|
+
for r in range(body_h):
|
|
690
|
+
line = ""
|
|
691
|
+
cur_color = ''
|
|
692
|
+
for c_idx in range(W):
|
|
693
|
+
cell_color = colors[r][c_idx]
|
|
694
|
+
if cell_color != cur_color:
|
|
695
|
+
if cur_color:
|
|
696
|
+
line += '\033[0m'
|
|
697
|
+
line += cell_color
|
|
698
|
+
cur_color = cell_color
|
|
699
|
+
line += canvas[r][c_idx]
|
|
700
|
+
if cur_color:
|
|
701
|
+
line += '\033[0m'
|
|
702
|
+
out.append(wline(start + r, line))
|
|
703
|
+
|
|
704
|
+
def draw_edge(canvas, colors, W, H, x1, y1, x2, y2,
|
|
705
|
+
bl, br, bt, bb):
|
|
706
|
+
"""Draw a line between two points, skipping the center box area."""
|
|
707
|
+
dx = x2 - x1
|
|
708
|
+
dy = y2 - y1
|
|
709
|
+
steps = max(abs(dx), abs(dy))
|
|
710
|
+
if steps == 0:
|
|
711
|
+
return
|
|
712
|
+
xi = dx / steps
|
|
713
|
+
yi = dy / steps
|
|
714
|
+
x, y = float(x1), float(y1)
|
|
715
|
+
for _ in range(steps):
|
|
716
|
+
x += xi
|
|
717
|
+
y += yi
|
|
718
|
+
ix, iy = int(round(x)), int(round(y))
|
|
719
|
+
if not (0 <= ix < W and 0 <= iy < H):
|
|
720
|
+
continue
|
|
721
|
+
# Skip center box area
|
|
722
|
+
if bl <= ix < br and bt <= iy <= bb:
|
|
723
|
+
continue
|
|
724
|
+
# Only draw on empty cells
|
|
725
|
+
if canvas[iy][ix] != ' ':
|
|
726
|
+
continue
|
|
727
|
+
# Pick character based on direction
|
|
728
|
+
adx, ady = abs(dx), abs(dy)
|
|
729
|
+
if ady < adx * 0.3:
|
|
730
|
+
ch = '\u2500' # ─
|
|
731
|
+
elif adx < ady * 0.3:
|
|
732
|
+
ch = '\u2502' # │
|
|
733
|
+
elif (dx > 0) == (dy > 0):
|
|
734
|
+
ch = '\u00b7' # ·
|
|
735
|
+
else:
|
|
736
|
+
ch = '\u00b7' # ·
|
|
737
|
+
canvas[iy][ix] = ch
|
|
738
|
+
colors[iy][ix] = '\033[90m'
|
|
739
|
+
|
|
740
|
+
# ── input handling ──────────────────────────────────
|
|
741
|
+
|
|
742
|
+
def handle(c):
|
|
743
|
+
if ui.search_mode:
|
|
744
|
+
return handle_search_input(c)
|
|
745
|
+
if c == '\x1b':
|
|
746
|
+
return handle_esc()
|
|
747
|
+
if c == 'q':
|
|
748
|
+
if ui.detail:
|
|
749
|
+
ui.detail = False
|
|
750
|
+
ui.detail_scroll = 0
|
|
751
|
+
ui.status = ""
|
|
752
|
+
else:
|
|
753
|
+
return False
|
|
754
|
+
elif c == '\t':
|
|
755
|
+
ui.tab = (ui.tab + 1) % len(ui.tabs)
|
|
756
|
+
ui.sel = 0
|
|
757
|
+
ui.scroll = 0
|
|
758
|
+
ui.detail = False
|
|
759
|
+
ui.status = ""
|
|
760
|
+
elif c == 'k':
|
|
761
|
+
nav_up()
|
|
762
|
+
elif c == 'j':
|
|
763
|
+
nav_down()
|
|
764
|
+
elif c in ('\r', '\n'):
|
|
765
|
+
do_enter()
|
|
766
|
+
elif c == '/':
|
|
767
|
+
ui.search_mode = True
|
|
768
|
+
ui.search_buf = ""
|
|
769
|
+
ui.status = ""
|
|
770
|
+
elif c == 'g':
|
|
771
|
+
if ui.tab != 4:
|
|
772
|
+
cycle_gen()
|
|
773
|
+
elif c in ('\x7f', '\x08'):
|
|
774
|
+
# Backspace: go back in graph history
|
|
775
|
+
if ui.tab == 4 and ui.graph_history:
|
|
776
|
+
ui.graph_center = ui.graph_history.pop()
|
|
777
|
+
update_graph_neighbors()
|
|
778
|
+
ui.status = f"Back to: {ui.graph_center}"
|
|
779
|
+
return True
|
|
780
|
+
|
|
781
|
+
def handle_esc():
|
|
782
|
+
if select.select([sys.stdin], [], [], 0.05)[0]:
|
|
783
|
+
c2 = sys.stdin.read(1)
|
|
784
|
+
if c2 == '[':
|
|
785
|
+
c3 = sys.stdin.read(1)
|
|
786
|
+
if c3 == 'A':
|
|
787
|
+
nav_up()
|
|
788
|
+
elif c3 == 'B':
|
|
789
|
+
nav_down()
|
|
790
|
+
else:
|
|
791
|
+
if ui.detail:
|
|
792
|
+
ui.detail = False
|
|
793
|
+
ui.detail_scroll = 0
|
|
794
|
+
ui.status = ""
|
|
795
|
+
return True
|
|
796
|
+
|
|
797
|
+
def handle_search_input(c):
|
|
798
|
+
if c == '\x1b':
|
|
799
|
+
if select.select([sys.stdin], [], [], 0.05)[0]:
|
|
800
|
+
c2 = sys.stdin.read(1)
|
|
801
|
+
if c2 == '[':
|
|
802
|
+
sys.stdin.read(1)
|
|
803
|
+
else:
|
|
804
|
+
ui.search_mode = False
|
|
805
|
+
ui.search_buf = ""
|
|
806
|
+
ui.status = "Search cancelled"
|
|
807
|
+
elif c in ('\r', '\n'):
|
|
808
|
+
if ui.search_buf.strip():
|
|
809
|
+
do_search(ui.search_buf.strip())
|
|
810
|
+
ui.tab = 3
|
|
811
|
+
ui.sel = 0
|
|
812
|
+
ui.scroll = 0
|
|
813
|
+
ui.status = f"Found {len(ui.search_results)} results for '{ui.search_buf.strip()}'"
|
|
814
|
+
ui.search_mode = False
|
|
815
|
+
ui.search_buf = ""
|
|
816
|
+
elif c in ('\x7f', '\x08'):
|
|
817
|
+
ui.search_buf = ui.search_buf[:-1]
|
|
818
|
+
elif c == '\x15':
|
|
819
|
+
ui.search_buf = ""
|
|
820
|
+
elif len(c) == 1 and 32 <= ord(c) <= 126:
|
|
821
|
+
ui.search_buf += c
|
|
822
|
+
return True
|
|
823
|
+
|
|
824
|
+
def nav_up():
|
|
825
|
+
if ui.detail:
|
|
826
|
+
ui.detail_scroll = max(0, ui.detail_scroll - 1)
|
|
827
|
+
elif ui.tab == 4:
|
|
828
|
+
# Graph tab: cycle through neighbors
|
|
829
|
+
if ui.graph_neighbors:
|
|
830
|
+
ui.graph_sel = (ui.graph_sel - 1) % len(ui.graph_neighbors)
|
|
831
|
+
ui.status = ui.graph_neighbors[ui.graph_sel]
|
|
832
|
+
else:
|
|
833
|
+
ui.sel = max(0, ui.sel - 1)
|
|
834
|
+
if ui.sel < ui.scroll:
|
|
835
|
+
ui.scroll = ui.sel
|
|
836
|
+
ui.status = ""
|
|
837
|
+
|
|
838
|
+
def nav_down():
|
|
839
|
+
_, H = term_size()
|
|
840
|
+
body_h = H - 8
|
|
841
|
+
if body_h < 1:
|
|
842
|
+
body_h = 1
|
|
843
|
+
if ui.detail:
|
|
844
|
+
max_scroll = max(0, len(ui.detail_lines) - body_h)
|
|
845
|
+
ui.detail_scroll = min(max_scroll, ui.detail_scroll + 1)
|
|
846
|
+
elif ui.tab == 4:
|
|
847
|
+
# Graph tab: cycle through neighbors
|
|
848
|
+
if ui.graph_neighbors:
|
|
849
|
+
ui.graph_sel = (ui.graph_sel + 1) % len(ui.graph_neighbors)
|
|
850
|
+
ui.status = ui.graph_neighbors[ui.graph_sel]
|
|
851
|
+
else:
|
|
852
|
+
mx = max(0, current_list_len() - 1)
|
|
853
|
+
ui.sel = min(mx, ui.sel + 1)
|
|
854
|
+
if ui.sel >= ui.scroll + body_h:
|
|
855
|
+
ui.scroll = ui.sel - body_h + 1
|
|
856
|
+
ui.status = ""
|
|
857
|
+
|
|
858
|
+
def current_list_len():
|
|
859
|
+
if ui.tab == 0:
|
|
860
|
+
return len(ui.facts)
|
|
861
|
+
elif ui.tab == 1:
|
|
862
|
+
return len(ui.concepts)
|
|
863
|
+
elif ui.tab == 2:
|
|
864
|
+
return len(ui.links)
|
|
865
|
+
elif ui.tab == 3:
|
|
866
|
+
return len(ui.search_results)
|
|
867
|
+
return 0
|
|
868
|
+
|
|
869
|
+
def do_enter():
|
|
870
|
+
if ui.detail:
|
|
871
|
+
return
|
|
872
|
+
if ui.tab == 0 and ui.facts:
|
|
873
|
+
load_fact_detail(ui.sel)
|
|
874
|
+
ui.detail = True
|
|
875
|
+
ui.detail_scroll = 0
|
|
876
|
+
elif ui.tab == 1 and ui.concepts:
|
|
877
|
+
load_concept_detail(ui.sel)
|
|
878
|
+
ui.detail = True
|
|
879
|
+
ui.detail_scroll = 0
|
|
880
|
+
elif ui.tab == 2 and ui.links:
|
|
881
|
+
# for a link, show details of source item
|
|
882
|
+
lnk = ui.links[ui.sel]
|
|
883
|
+
lines = []
|
|
884
|
+
lines.append(("\033[1mLink:\033[0m", ""))
|
|
885
|
+
lines.append((f" \033[33mSource:\033[0m {lnk['source']}", ""))
|
|
886
|
+
lines.append((f" \033[33mTarget:\033[0m {lnk['target']}", ""))
|
|
887
|
+
lines.append((f" \033[33mType:\033[0m {lnk['type']}", ""))
|
|
888
|
+
ui.detail_lines = lines
|
|
889
|
+
ui.detail = True
|
|
890
|
+
ui.detail_scroll = 0
|
|
891
|
+
elif ui.tab == 3 and ui.search_results:
|
|
892
|
+
load_search_detail(ui.sel)
|
|
893
|
+
ui.detail = True
|
|
894
|
+
ui.detail_scroll = 0
|
|
895
|
+
elif ui.tab == 4 and ui.graph_neighbors:
|
|
896
|
+
# Recenter graph on selected neighbor
|
|
897
|
+
new_center = ui.graph_neighbors[ui.graph_sel]
|
|
898
|
+
ui.graph_history.append(ui.graph_center)
|
|
899
|
+
ui.graph_center = new_center
|
|
900
|
+
update_graph_neighbors()
|
|
901
|
+
ui.status = f"Centered: {new_center}"
|
|
902
|
+
|
|
903
|
+
def cycle_gen():
|
|
904
|
+
if ui.gen_filter is None:
|
|
905
|
+
ui.gen_filter = 0
|
|
906
|
+
elif ui.gen_filter >= ui.max_gen:
|
|
907
|
+
ui.gen_filter = None
|
|
908
|
+
else:
|
|
909
|
+
ui.gen_filter = ui.gen_filter + 1
|
|
910
|
+
ui.sel = 0
|
|
911
|
+
ui.scroll = 0
|
|
912
|
+
ui.detail = False
|
|
913
|
+
load_facts()
|
|
914
|
+
load_concepts()
|
|
915
|
+
if ui.gen_filter is not None:
|
|
916
|
+
ui.status = f"Filter: generation {ui.gen_filter}"
|
|
917
|
+
else:
|
|
918
|
+
ui.status = "Filter: all generations"
|
|
919
|
+
|
|
920
|
+
# ── main loop ──────────────────────────────────────
|
|
921
|
+
load_all_data()
|
|
922
|
+
fd = sys.stdin.fileno()
|
|
923
|
+
old_attrs = termios.tcgetattr(fd)
|
|
924
|
+
|
|
925
|
+
try:
|
|
926
|
+
tty.setcbreak(fd)
|
|
927
|
+
sys.stdout.write('\033[?25l')
|
|
928
|
+
sys.stdout.write('\033[2J\033[H')
|
|
929
|
+
sys.stdout.flush()
|
|
930
|
+
render()
|
|
931
|
+
while True:
|
|
932
|
+
c = sys.stdin.read(1)
|
|
933
|
+
if not handle(c):
|
|
934
|
+
break
|
|
935
|
+
render()
|
|
936
|
+
finally:
|
|
937
|
+
termios.tcsetattr(fd, termios.TCSADRAIN, old_attrs)
|
|
938
|
+
sys.stdout.write('\033[?25h\033[2J\033[H')
|
|
939
|
+
sys.stdout.flush()
|
|
940
|
+
|
|
941
|
+
context['output'] = "KG browser closed."
|