kira-wallet 0.1.0

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 (58) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +116 -0
  3. package/THIRD_PARTY_NOTICES.md +13 -0
  4. package/bin/kira.cjs +8 -0
  5. package/curve_pricing.py +86 -0
  6. package/docs/AGENT_TOOLS.md +53 -0
  7. package/docs/CLI.md +144 -0
  8. package/docs/JOB_CONTRACT.md +111 -0
  9. package/docs/KIRA_JOBS_BUILD_REPORT.md +92 -0
  10. package/docs/KIRA_REDESIGN_REPORT.md +125 -0
  11. package/docs/ONBOARDING_CHAT_CONTRACT.md +36 -0
  12. package/docs/RELEASE.md +45 -0
  13. package/docs/WALLET_CAPABILITIES.md +98 -0
  14. package/docs/character/brand-generation.json +20 -0
  15. package/docs/character/workspace-generation.json +29 -0
  16. package/docs/screenshots/kira-jobs-desktop.jpg +0 -0
  17. package/kira_agent.py +280 -0
  18. package/kira_cli.py +263 -0
  19. package/kira_config.py +98 -0
  20. package/kira_jobs.py +504 -0
  21. package/kira_tools.py +104 -0
  22. package/networks.json +214 -0
  23. package/onchain.cjs +34 -0
  24. package/package.json +78 -0
  25. package/pipeline-onchain.cjs +284 -0
  26. package/research.py +39 -0
  27. package/sources/job-envelope-v1.schema.json +469 -0
  28. package/sources/job-request-v1.schema.json +352 -0
  29. package/sources/mintclub-bond-abi.json +1457 -0
  30. package/sources/rpc-candidates.json +294 -0
  31. package/sources/uniswap-deployments.json +18261 -0
  32. package/viewer/README.md +119 -0
  33. package/viewer/chain_images.py +21 -0
  34. package/viewer/details.py +119 -0
  35. package/viewer/model.py +216 -0
  36. package/viewer/refresh-curve-prices.cjs +35 -0
  37. package/viewer/refresh_prices.py +97 -0
  38. package/viewer/server.py +153 -0
  39. package/viewer/static/agent.css +100 -0
  40. package/viewer/static/agent.js +203 -0
  41. package/viewer/static/app.js +252 -0
  42. package/viewer/static/favicon.svg +1 -0
  43. package/viewer/static/index.html +139 -0
  44. package/viewer/static/jobs.js +113 -0
  45. package/viewer/static/kira-attention.png +0 -0
  46. package/viewer/static/kira-explain.png +0 -0
  47. package/viewer/static/kira-logo.png +0 -0
  48. package/viewer/static/kira-research.png +0 -0
  49. package/viewer/static/kira-review.png +0 -0
  50. package/viewer/static/kira.png +0 -0
  51. package/viewer/static/style.css +51 -0
  52. package/viewer/static/watching-model.js +129 -0
  53. package/viewer/static/watching.js +167 -0
  54. package/viewer/static/workspace-model.js +43 -0
  55. package/viewer/static/workspace.css +463 -0
  56. package/viewer/static/workspace.js +102 -0
  57. package/viewer/token_images.py +137 -0
  58. package/wallet.py +389 -0
@@ -0,0 +1,153 @@
1
+ """Loopback viewer with explicitly enabled, session-protected local controls."""
2
+ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
3
+ from pathlib import Path
4
+ import argparse
5
+ import json
6
+ import os
7
+ import threading
8
+ import secrets
9
+ import sys
10
+ import signal
11
+ from urllib.parse import urlparse, parse_qs
12
+ sys.path.insert(0,str(Path(__file__).resolve().parents[1]))
13
+ from kira_jobs import JobStore, JobError
14
+ from kira_agent import AgentStore
15
+ from model import ROOT, load_state, within
16
+ from token_images import IMAGE_HOSTS
17
+
18
+ STATIC=Path(__file__).resolve().parent/'static'
19
+ lock=threading.Lock()
20
+ last_good=None
21
+
22
+ class Handler(BaseHTTPRequestHandler):
23
+ def local_request(self):
24
+ expected='127.0.0.1:'+str(self.server.server_port)
25
+ if self.headers.get('Host')!=expected or self.headers.get('Origin', 'http://'+expected)!='http://'+expected or self.headers.get('Sec-Fetch-Site') in ('cross-site','same-site'):
26
+ self.send_error(403,'Local origin required.');return False
27
+ return True
28
+
29
+ def authenticated(self):
30
+ if not getattr(self.server,'controls',False):self.send_error(403,'Local controls are disabled.');return False
31
+ if not secrets.compare_digest(self.headers.get('X-Kira-Session','').encode(),self.server.session_token.encode()):self.send_error(403,'Local session required.');return False
32
+ return True
33
+
34
+ def json(self,value,status=200):
35
+ self.respond(json.dumps(value).encode(),'application/json; charset=utf-8',status=status)
36
+
37
+ def do_POST(self):
38
+ if not self.local_request():return
39
+ if not getattr(self.server,'controls',False):self.send_error(501,'Local controls are disabled.');return
40
+ if not self.authenticated():return
41
+ expected='http://127.0.0.1:'+str(self.server.server_port)
42
+ if self.headers.get('Origin')!=expected:self.send_error(403,'Same origin required.');return
43
+ if self.headers.get('Content-Type')!='application/json':self.send_error(415);return
44
+ path=urlparse(self.path).path
45
+ if path not in ('/api/operations','/api/agent/test','/api/agent/settings','/api/chat/send','/api/chat/reset','/api/chat/cancel'):self.send_error(404);return
46
+ try:
47
+ self.connection.settimeout(5)
48
+ length=int(self.headers.get('Content-Length','0'))
49
+ if not 0<length<=65536:self.send_error(413);return
50
+ request=json.loads(self.rfile.read(length),parse_constant=lambda _: (_ for _ in ()).throw(ValueError()))
51
+ if not isinstance(request,dict):raise ValueError()
52
+ if path!='/api/operations':
53
+ if path in ('/api/agent/test','/api/chat/send') and json.loads((ROOT/'wallets.json').read_text()).get('demo'):
54
+ raise JobError('demo_read_only','The synthetic demo does not contact model services. Run kira setup with your personal data directory.')
55
+ if path=='/api/agent/test':result=self.server.agent.start(request,test=True)
56
+ elif path=='/api/agent/settings':result=self.server.agent.configure(request)
57
+ elif path=='/api/chat/send':result=self.server.agent.start(request)
58
+ elif path=='/api/chat/reset':
59
+ if request:raise ValueError()
60
+ result=self.server.agent.reset()
61
+ else:
62
+ if set(request)!={'id'}:raise ValueError()
63
+ result=self.server.agent.cancel(request['id'])
64
+ self.json(result,202 if path in ('/api/agent/test','/api/chat/send') else 200);return
65
+ if json.loads((ROOT/'wallets.json').read_text()).get('demo'):
66
+ operation=request.get('operation')
67
+ if operation=='job.resume' and isinstance(request.get('input'),dict):
68
+ operation=self.server.jobs.get(request['input'].get('job_id'))['operation']
69
+ if operation in ('wallet.add','wallet.refresh','prices.refresh'):
70
+ raise JobError('demo_read_only','Sample research cannot contact providers. Use a separate personal data directory.')
71
+ job=self.server.jobs.submit(request)
72
+ if job['state']=='queued':self.server.jobs.launch()
73
+ self.json(job,202)
74
+ except JobError as error:self.json({'error':{'code':error.code,'message':str(error)}},409 if error.code=='idempotency_conflict' else 400)
75
+ except (ValueError,TypeError):self.json({'error':{'code':'invalid_request','message':'Expected a valid operation request.'}},400)
76
+ except OSError:self.json({'error':{'code':'unavailable','message':'Local storage is temporarily unavailable.'}},503)
77
+
78
+ def do_GET(self):
79
+ global last_good
80
+ if not self.local_request():return
81
+ path=urlparse(self.path).path
82
+ try:
83
+ if path=='/api/health':
84
+ self.json({'instance':os.environ.get('KIRA_INSTANCE_ID'),'read_only':not getattr(self.server,'controls',False),'controls':getattr(self.server,'controls',False)});return
85
+ if path=='/api/session':
86
+ self.json({'controls':getattr(self.server,'controls',False),'token':self.server.session_token if getattr(self.server,'controls',False) else None});return
87
+ if path.startswith('/api/jobs') or path.startswith('/api/chat/turn/') or path in ('/api/agent','/api/chat','/api/snapshots','/api/snapshot','/api/compare','/api/settings'):
88
+ if not self.authenticated():return
89
+ query=parse_qs(urlparse(self.path).query)
90
+ if path in ('/api/agent','/api/chat'):
91
+ if json.loads((ROOT/'wallets.json').read_text()).get('demo'):
92
+ self.json({'config':None,'providers':[],'conversation_id':None,'messages':[],'active_turn':None})
93
+ else:self.json(self.server.agent.status(query.get('recheck')==['1']))
94
+ elif path.startswith('/api/chat/turn/'):self.json(self.server.agent.read(path.removeprefix('/api/chat/turn/')))
95
+ elif path=='/api/jobs':self.json(self.server.jobs.list())
96
+ elif path.startswith('/api/jobs/'):self.json(self.server.jobs.get(path.removeprefix('/api/jobs/')))
97
+ elif path=='/api/snapshots':self.json(self.server.jobs.snapshots(query.get('wallet',[''])[0]))
98
+ elif path=='/api/snapshot':self.json(self.server.jobs.snapshot(query.get('id',[''])[0]))
99
+ elif path=='/api/compare':self.json(self.server.jobs.compare(query.get('before',[''])[0],query.get('after',[''])[0]))
100
+ elif path=='/api/settings':
101
+ from kira_config import load_config
102
+ config=load_config();self.json({key:config[key] for key in ('schema_version','rpc','discovery')})
103
+ else:self.send_error(404)
104
+ return
105
+ if path=='/api/state':
106
+ with lock:
107
+ try:
108
+ _,body,etag=load_state();last_good=(body,etag)
109
+ except (json.JSONDecodeError,FileNotFoundError):
110
+ if last_good is None:raise
111
+ body,etag=last_good
112
+ if self.headers.get('If-None-Match')=='"'+etag+'"':
113
+ self.send_response(304);self.send_header('ETag','"'+etag+'"');self.send_header('Cache-Control','no-cache');self.end_headers();return
114
+ self.respond(body,'application/json; charset=utf-8',etag);return
115
+ if path.startswith('/api/report/'):
116
+ key=path.removeprefix('/api/report/')
117
+ registry=json.loads((ROOT/'wallets.json').read_text())
118
+ entry=next((w for w in registry['wallets'] if w['address_key']==key),None)
119
+ if not entry or not entry.get('latest_snapshot',{}).get('report'):self.send_error(404);return
120
+ body=within(ROOT,entry['latest_snapshot']['report']).read_bytes()
121
+ self.respond(body,'text/plain; charset=utf-8');return
122
+ files={'/':'index.html','/index.html':'index.html','/app.js':'app.js','/jobs.js':'jobs.js','/workspace.js':'workspace.js','/workspace-model.js':'workspace-model.js','/workspace.css':'workspace.css','/agent.js':'agent.js','/agent.css':'agent.css','/watching.js':'watching.js','/watching-model.js':'watching-model.js','/style.css':'style.css','/favicon.svg':'favicon.svg','/kira.png':'kira.png','/kira-logo.png':'kira-logo.png',**{f'/kira-{pose}.png':f'kira-{pose}.png' for pose in ('research','explain','review','attention')}}
123
+ if path not in files:self.send_error(404);return
124
+ file=STATIC/files[path]
125
+ content={'html':'text/html; charset=utf-8','js':'text/javascript; charset=utf-8','css':'text/css; charset=utf-8','svg':'image/svg+xml','png':'image/png'}[file.suffix[1:]]
126
+ self.respond(file.read_bytes(),content)
127
+ except JobError as error:self.json({'error':{'code':error.code,'message':str(error)}},400)
128
+ except (ValueError,OSError,json.JSONDecodeError):self.send_error(503,'Research data is being updated. Try again shortly.')
129
+
130
+ def respond(self,body,content,etag=None,status=200):
131
+ self.send_response(status);self.send_header('Content-Type',content);self.send_header('Content-Length',str(len(body)))
132
+ self.send_header('Cache-Control','no-cache');self.send_header('X-Content-Type-Options','nosniff')
133
+ self.send_header('Referrer-Policy','no-referrer');self.send_header('X-Frame-Options','DENY')
134
+ image_origins=' '.join('https://'+host for host in IMAGE_HOSTS)
135
+ self.send_header('Content-Security-Policy',"default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data: "+image_origins+"; connect-src 'self'; frame-ancestors 'none'")
136
+ if etag:self.send_header('ETag','"'+etag+'"')
137
+ self.end_headers();self.wfile.write(body)
138
+
139
+ def log_message(self,*args): pass
140
+
141
+ if __name__=='__main__':
142
+ parser=argparse.ArgumentParser();parser.add_argument('--port',type=int,default=8765);parser.add_argument('--controls',action='store_true');args=parser.parse_args()
143
+ server=ThreadingHTTPServer(('127.0.0.1',args.port),Handler)
144
+ server.controls=args.controls;server.session_token=secrets.token_urlsafe(32)
145
+ if args.controls:server.jobs=JobStore(ROOT);server.agent=AgentStore(ROOT)
146
+ def stop_viewer(*_):raise KeyboardInterrupt()
147
+ signal.signal(signal.SIGTERM,stop_viewer)
148
+ print(f'Wallet viewer: http://127.0.0.1:{args.port}',flush=True)
149
+ try:server.serve_forever()
150
+ except KeyboardInterrupt:pass
151
+ finally:
152
+ if args.controls:server.agent.shutdown()
153
+ server.server_close()
@@ -0,0 +1,100 @@
1
+ /* A distinct, reachable composer and a deliberate account setup flow. */
2
+ .composer { padding: 16px 18px 14px; background: #f8f6fb; }
3
+ .composer-top { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-bottom: 9px; }
4
+ .composer-top label { font-size: 13px; line-height: 20px; font-weight: 600; color: #493254; }
5
+ #chat-status { color: #715c7d; font-size: 11px; line-height: 18px; }
6
+ .composer-box { background: #fff; border: 1px solid #bcb0c8; border-radius: 14px; box-shadow: 0 2px 8px #3a294708; transition: border-color .15s, box-shadow .15s; }
7
+ .composer-box:focus-within { border-color: #79558d; box-shadow: 0 0 0 3px #79558d1a; }
8
+ .composer textarea { resize: none; min-height: 78px; max-height: 160px; padding: 13px 14px 6px; font-size: 14px; line-height: 1.6; outline: none; }
9
+ .composer textarea::placeholder { color: #74677e; }
10
+ .composer-footer { padding: 4px 8px 8px 10px; gap: 8px; }
11
+ .model-button { display: inline-flex; align-items: center; gap: 7px; min-width: 0; min-height: 44px; max-width: calc(100% - 52px); padding: 6px 9px; border: 0; background: transparent; color: #574164; font-size: 12px; line-height: 18px; border-radius: 8px; }
12
+ .model-button:hover { background: #f0edf5; }
13
+ .model-button > span:nth-child(2) { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
14
+ .model-button .ui-icon { width: 16px; height: 16px; }
15
+ .model-dot { width: 6px; height: 6px; flex: none; border-radius: 50%; background: #9e90a7; }
16
+ .model-button.connected .model-dot { background: #52795e; }
17
+ .send-button { display: inline-flex; align-items: center; justify-content: center; width: 44px; height: 44px; flex: none; border-radius: 11px; padding: 0; background: #6b477d; color: white; opacity: 1; }
18
+ .send-button:hover:not(:disabled) { background: #50305f; }
19
+ .send-button:disabled { background: #eee8f3; color: #9c8aaa; cursor: default; }
20
+ .send-button .ui-icon { width: 20px; height: 20px; stroke-width: 1.8; }
21
+ .stop-button { background: #574164; }
22
+ .composer-context { display: grid; gap: 4px; margin-top: 9px; color: #766480; font-size: 11px; line-height: 16px; }
23
+ #chat-context { font-weight: 500; overflow-wrap: anywhere; }
24
+ #chat-hint { color: #766480; font-size: 11px; }
25
+ .composer .form-error:empty { display: none; }
26
+ .chat-new { width: 44px; height: 44px; display: flex; align-items: center; justify-content: center; background: #ffffff60; border: 1px solid #dcd2e4; color: #654378; border-radius: 10px; flex: none; }
27
+ .chat-new .ui-icon { width: 19px; height: 19px; }
28
+ .chat-messages { display: grid; gap: 18px; }
29
+ .chat-message { font-size: 14px; line-height: 1.7; color: #48374f; overflow-wrap: anywhere; }
30
+ .chat-message p { margin: 6px 0 0; white-space: pre-wrap; }
31
+ .chat-message strong { font-size: 11px; font-weight: 600; color: #745c81; }
32
+ .chat-message.user { margin-left: 24px; padding: 12px 14px; border-radius: 13px 13px 3px 13px; background: #e6deed; }
33
+ .chat-message.assistant { padding: 0 1px; }
34
+ .chat-message.pending { color: #80688c; font-size: 13px; }
35
+ .kira-panel.chatting .kira-scene { height: 72px; }
36
+ .kira-panel.chatting .kira-scene img { width: 76px; height: 86px; bottom: -8px; }
37
+ .kira-panel.chatting .scene-caption { max-width: 70%; padding-top: 16px; gap: 6px; }
38
+ .kira-panel.chatting .scene-caption > span:first-child { font-size: 17px; line-height: 22px; }
39
+ .kira-panel.chatting .scene-rule { display: none; }
40
+ .kira-panel.chatting .scene-caption small { font-size: 11px; max-width: none; }
41
+ .kira-panel.chatting #kira-note, .kira-panel.chatting .kira-quick-actions, .kira-panel.chatting .briefing-meta { display: none; }
42
+ .kira-panel.chatting .kira-conversation { padding-top: 18px; }
43
+ .agent-dialog { width: min(590px, calc(100% - 32px)); max-height: calc(100dvh - 40px); padding: 28px; border-radius: 20px; }
44
+ .agent-dialog .dialog-heading { margin-bottom: 22px; align-items: flex-start; }
45
+ .agent-dialog h2 { font-size: 27px; line-height: 1.25; letter-spacing: -.03em; font-weight: 600; }
46
+ .agent-dialog .eyebrow { font-size: 10px; letter-spacing: .08em; color: #785e85; }
47
+ .agent-dialog .dialog-heading { justify-content: space-between; }
48
+ .agent-dialog h3 { font-size: 19px; line-height: 1.4; letter-spacing: -.02em; font-weight: 600; margin: 6px 0 10px; }
49
+ .setup-progress { list-style: none; padding: 0; display: flex; justify-content: space-between; gap: 12px; margin: 0 0 24px; }
50
+ .setup-progress li { display: flex; align-items: center; gap: 7px; font-size: 12px; color: #715c7d; }
51
+ .setup-progress span { display: grid; place-items: center; width: 24px; height: 24px; border: 1px solid #d7cedd; border-radius: 50%; font-size: 11px; }
52
+ .setup-progress [aria-current] { color: #5b356e; font-weight: 600; }
53
+ .setup-progress [aria-current] span { color: white; background: #6b477d; border-color: #6b477d; }
54
+ .setup-intro { font-size: 14px; color: #6a5d71; line-height: 1.65; margin: 0 0 18px; }
55
+ .provider-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
56
+ .provider-card { padding: 18px 15px; min-height: 116px; border: 1px solid #ded5e5; border-radius: 13px; background: #fcfafc; text-align: left; color: #4f3c59; display: grid; gap: 7px; }
57
+ .provider-card[aria-pressed=true] { border-color: #856497; background: #f0eaf5; box-shadow: 0 0 0 1px #856497; }
58
+ .provider-card strong { font-size: 15px; line-height: 22px; font-weight: 600; }
59
+ .provider-card small { color: #796a82; font-size: 11px; line-height: 17px; }
60
+ .provider-card .provider-status { font-size: 12px; line-height: 18px; }
61
+ .provider-status.ready { color: #3e7050; }
62
+ .cli-help { margin-top: 12px; font-size: 12px; line-height: 1.6; color: #796a82; }
63
+ .cli-command { display: flex; align-items: center; gap: 8px; margin-top: 8px; padding: 9px 12px; background: #f4f0f7; border-radius: 8px; }
64
+ .cli-command code { flex: 1; min-width: 0; overflow-wrap: anywhere; font-size: 11px; }
65
+ .cli-command button { min-height: 44px; border: 0; background: none; color: #654378; font-size: 12px; }
66
+ .agent-dialog label { font-size: 13px; line-height: 1.5; }
67
+ .model-field { margin-top: 12px; }
68
+ .model-field span { font-weight: 400; color: #715c7d; }
69
+ .scope-choices { border: 0; margin: 0; padding: 0; display: grid; gap: 9px; }
70
+ .scope-choices label { display: flex; gap: 11px; align-items: flex-start; margin: 0; padding: 13px 14px; border: 1px solid #dfd6e6; border-radius: 11px; background: #fcfafc; }
71
+ .scope-choices label:has(input:checked) { background: #f1ebf6; border-color: #9478a3; }
72
+ .scope-choices input, .permission-choice input { width: 16px; height: 16px; flex: none; margin: 3px 0 0; accent-color: #6b477d; }
73
+ .agent-dialog .scope-choices input[type=radio] { width: 16px; height: 16px; min-height: 16px; flex: 0 0 16px; padding: 0; }
74
+ .agent-dialog .permission-choice input[type=checkbox] { width: 16px; height: 16px; min-height: 16px; flex: 0 0 16px; padding: 0; }
75
+ .scope-choices strong, .permission-choice strong { display: block; font-size: 13px; font-weight: 500; line-height: 20px; }
76
+ .scope-choices small, .permission-choice small { display: block; font-size: 12px; color: #796a82; line-height: 1.6; margin-top: 3px; }
77
+ .agent-dialog .permission-choice { display: flex; align-items: flex-start; gap: 11px; margin: 17px 0 0; }
78
+ .native-trust { padding-top: 16px; border-top: 1px solid #e8e0ed; }
79
+ .setup-footer { border-top: 1px solid #e8e0ed; margin-top: 22px; padding-top: 18px; display: flex; gap: 10px; justify-content: flex-end; }
80
+ .setup-footer button { min-height: 44px; }
81
+ .ready-art { margin: 5px 0 10px; }
82
+ .ready-art img { display: block; border-radius: 16px; }
83
+ .agent-review { display: grid; grid-template-columns: 90px 1fr; gap: 10px 16px; padding: 16px; background: #f6f2f8; border-radius: 12px; font-size: 13px; line-height: 20px; margin: 16px 0; }
84
+ .agent-review dt { color: #766480; }
85
+ .agent-review dd { margin: 0; color: #543d60; overflow-wrap: anywhere; }
86
+ #agent-test-status { font-size: 13px; line-height: 1.6; color: #4e7359; }
87
+ @media (max-width: 600px) {
88
+ .agent-dialog { padding: 22px 18px; }
89
+ .agent-dialog h2 { font-size: 25px; }
90
+ .provider-grid { gap: 10px; }
91
+ .provider-card { padding: 14px 12px; }
92
+ .composer textarea { font-size: 16px; }
93
+ #chat-hint { display: none; }
94
+ .setup-progress { gap: 8px; }
95
+ }
96
+ @media (prefers-reduced-motion: reduce) { .composer-box { transition: none; } }
97
+ @media (min-width: 981px) and (max-height: 760px) {
98
+ .kira-panel { position: static; height: auto; min-height: 620px; max-height: none; }
99
+ .kira-conversation { flex: none; overflow: visible; }
100
+ }
@@ -0,0 +1,203 @@
1
+ 'use strict';
2
+ let agentState = null, agentProvider = 'codex', agentStep = 1, setupRevision = 0, agentVerified = false;
3
+ let chatTurn = null, chatEpoch = 0, chatRequest = null, chatRetry = null, agentLoading = false, setupTesting = false, setupTurn = null, draftRevision = 0;
4
+ let chatSignature = '', agentReady = false, chatDraftRevision = 0;
5
+ const scopeNames = {none: 'No wallet context', wallet: 'One approved wallet', portfolio: 'Whole portfolio'};
6
+ const agentPost = (path, body) => localAPI(path, {method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify(body)});
7
+ function currentSetup() {
8
+ return {provider: agentProvider, model: $('agent-model').value.trim(), scope: document.querySelector('[name="agent-scope"]:checked').value,
9
+ wallet: $('agent-wallet').value || null, retain_history: $('agent-retain').checked, trust_native_cli: $('agent-trust').checked};
10
+ }
11
+ function invalidateSetup() { setupRevision++; agentVerified = false; $('agent-save').disabled = true; $('agent-test-status').textContent = ''; }
12
+ function scopeLabel(config) {
13
+ if (!config) return 'No wallet context shared';
14
+ if (config.scope === 'wallet') return 'Context: ' + (state?.wallets.find(w => w.key === config.wallet)?.name || 'approved wallet');
15
+ return config.scope === 'portfolio' ? 'Context: whole portfolio' : 'No wallet context shared';
16
+ }
17
+ function renderAgent() {
18
+ const config = agentState?.config;
19
+ $('chat-model-label').textContent = config ? (config.provider === 'codex' ? 'Codex' : 'Claude') + ' · ' + (config.model || 'CLI default') : 'Connect a model';
20
+ $('chat-setup').classList.toggle('connected', Boolean(config));
21
+ $('chat-context').textContent = scopeLabel(config);
22
+ $('chat-status').textContent = chatTurn ? 'Kira is thinking…' : config ? (config.retain_history ? 'History saved locally' : 'History in memory') : 'Connect your account';
23
+ $('chat-send').hidden = Boolean(chatTurn); $('chat-stop').hidden = !chatTurn;
24
+ $('chat-send').disabled = !config || !localSession?.controls || !$('kira-draft').value.trim() || Boolean(chatRequest);
25
+ const messages = agentState?.messages || [];
26
+ $('kira-panel').classList.toggle('chatting', Boolean(messages.length || chatTurn));
27
+ const signature = JSON.stringify([messages, chatTurn, chatRequest?.message]);
28
+ if (signature !== chatSignature) {
29
+ chatSignature = signature;
30
+ $('chat-messages').innerHTML = messages.map(message => `<article class="chat-message ${message.role === 'user' ? 'user' : 'assistant'}"><strong>${message.role === 'user' ? 'YOU' : 'KIRA'}</strong><p>${escapeHTML(message.text)}</p></article>`).join('') + (chatTurn && chatRequest?.message ? `<article class="chat-message user"><strong>YOU</strong><p>${escapeHTML(chatRequest.message)}</p></article>` : '') + (chatTurn ? '<div class="chat-message pending"><span class="spinner" aria-hidden="true"></span> Reading the context you approved…</div>' : '');
31
+ }
32
+ }
33
+ function renderProviders() {
34
+ $('agent-providers').innerHTML = (agentState?.providers || []).map(row => {
35
+ const ready = row.installed && row.supported && row.logged_in;
36
+ const label = !row.installed ? 'Install CLI' : !row.supported ? 'Unsupported version' : !row.logged_in ? 'Login needed' : 'Account detected';
37
+ return `<button class="provider-card" type="button" data-provider="${row.id}" aria-pressed="${agentProvider === row.id}"><strong>${escapeHTML(row.name)}</strong><small>${row.id === 'codex' ? 'Your ChatGPT account' : 'Your Claude account'}${row.version ? ' · ' + escapeHTML(row.version) : ''}</small><span class="provider-status ${ready ? 'ready' : ''}">${label}</span></button>`;
38
+ }).join('');
39
+ const selected = agentState?.providers.find(p => p.id === agentProvider);
40
+ $('agent-cli-help').innerHTML = !selected ? '' : selected.installed && selected.supported && selected.logged_in ? 'Your native account is ready for a response check. Kira never copies its credentials.' :
41
+ `<p>${!selected.installed ? 'Install the official CLI in your terminal, then sign in.' : !selected.supported ? 'Use a verified version: Codex 0.158.x or Claude Code 2.1.259–2.1.x.' : 'Sign in through the official CLI in your terminal.'} <a href="${selected.url}" target="_blank" rel="noreferrer">Official setup ↗</a></p>${(!selected.installed ? [selected.install, selected.login] : [selected.login]).map(command => `<div class="cli-command"><code>${escapeHTML(command)}</code><button type="button" data-copy-cli="${escapeHTML(command)}">Copy</button></div>`).join('')}`;
42
+ updateStep();
43
+ }
44
+ function updateStep() {
45
+ for (let step = 1; step <= 3; step++) {
46
+ $('agent-step-' + step).hidden = step !== agentStep;
47
+ const node = document.querySelector('.setup-progress [data-step="' + step + '"]');
48
+ if (step === agentStep) node.setAttribute('aria-current', 'step'); else node.removeAttribute('aria-current');
49
+ }
50
+ $('agent-back').hidden = agentStep === 1; $('agent-next').hidden = agentStep === 3; $('agent-save').hidden = agentStep !== 3;
51
+ const selected = agentState?.providers.find(p => p.id === agentProvider);
52
+ $('agent-next').disabled = agentStep === 1 ? !(selected?.installed && selected.supported && selected.logged_in) : !$('agent-trust').checked || (currentSetup().scope === 'wallet' && !$('agent-wallet').value);
53
+ $('agent-wallet-field').hidden = currentSetup().scope !== 'wallet';
54
+ if (agentStep === 3) {
55
+ const config = currentSetup();
56
+ $('agent-review').innerHTML = '<dt>Account</dt><dd>' + escapeHTML(selected?.name || '') + '</dd><dt>Model</dt><dd>' + escapeHTML(config.model || 'CLI default') + '</dd><dt>Context</dt><dd>' + escapeHTML(scopeLabel(config)) + '</dd><dt>History</dt><dd>' + (config.retain_history ? 'Saved on this computer' : 'Memory only') + '</dd>';
57
+ }
58
+ }
59
+ async function loadAgent(recheck = false) {
60
+ if (!localSession?.controls) return;
61
+ const epoch = chatEpoch;
62
+ const status = await localAPI('/api/agent' + (recheck ? '?recheck=1' : ''));
63
+ if (epoch !== chatEpoch) return;
64
+ agentState = status; renderAgent();
65
+ if (agentState.active_turn && !chatTurn && !chatRequest && !setupTesting) {
66
+ const id = agentState.active_turn;
67
+ // Keep Stop available even when the first recovery lookup loses its response.
68
+ chatTurn = id; chatDraftRevision = draftRevision; renderAgent();
69
+ let turn;
70
+ try { turn = await localAPI('/api/chat/turn/' + encodeURIComponent(id)); }
71
+ catch { if (epoch === chatEpoch) { recoverResponse(id, epoch); agentReady = true; } return; }
72
+ if (epoch !== chatEpoch) return;
73
+ if (turn.state === 'running') {
74
+ chatTurn = turn.id; chatRequest = turn.test ? null : {message: turn.message, conversation_id: turn.conversation_id, idempotency_key: turn.id}; renderAgent();
75
+ recoverResponse(turn.id, epoch);
76
+ } else {
77
+ await recoverResponse(id, epoch);
78
+ }
79
+ }
80
+ agentReady = true;
81
+ }
82
+ async function openAgent() {
83
+ if (state?.demo) { toast('The sample workspace does not contact model services. Run kira setup for your personal workspace.'); return; }
84
+ if (!localSession?.controls) { toast('Start your local workspace with kira setup to enable connection settings.'); return; }
85
+ $('agent-error').textContent = ''; $('agent-providers').innerHTML = '<p>Checking installed accounts…</p>';
86
+ $('agent-dialog').showModal(); agentStep = 1; invalidateSetup();
87
+ try {
88
+ await loadAgent();
89
+ const config = agentState.config;
90
+ agentProvider = config?.provider || agentState.providers.find(p => p.installed && p.supported && p.logged_in)?.id || 'codex';
91
+ $('agent-model').value = config?.model || '';
92
+ document.querySelector('[name="agent-scope"][value="' + (config?.scope || 'none') + '"]').checked = true;
93
+ $('agent-wallet').innerHTML = '<option value="">Choose a wallet</option>' + (state?.wallets || []).map(w => `<option value="${escapeHTML(w.key)}">${escapeHTML(w.name)}</option>`).join('');
94
+ $('agent-wallet').value = config?.wallet || ''; $('agent-retain').checked = config?.retain_history || false; $('agent-trust').checked = config?.trust_native_cli || false;
95
+ renderProviders();
96
+ } catch (error) { $('agent-error').textContent = error.message; }
97
+ }
98
+ $('chat-setup').addEventListener('click', openAgent);
99
+ $('settings-agent').addEventListener('click', () => { $('settings-dialog').close(); openAgent(); });
100
+ $('agent-providers').addEventListener('click', event => {
101
+ const button = event.target.closest('[data-provider]'); if (!button || setupTesting) return;
102
+ if (agentProvider !== button.dataset.provider) { agentProvider = button.dataset.provider; $('agent-model').value = ''; invalidateSetup(); renderProviders(); }
103
+ });
104
+ $('agent-cli-help').addEventListener('click', async event => {
105
+ const button = event.target.closest('[data-copy-cli]'); if (!button) return;
106
+ try { await navigator.clipboard.writeText(button.dataset.copyCli); button.textContent = 'Copied'; } catch { toast('Select and copy the command in your terminal.'); }
107
+ });
108
+ for (const id of ['agent-model', 'agent-wallet', 'agent-retain', 'agent-trust']) $(id).addEventListener('input', () => { invalidateSetup(); updateStep(); });
109
+ document.querySelectorAll('[name="agent-scope"]').forEach(input => input.addEventListener('change', () => { invalidateSetup(); updateStep(); }));
110
+ $('agent-next').addEventListener('click', () => { if (!$('agent-next').disabled) { agentStep++; updateStep(); $('agent-dialog').scrollTop = 0; } });
111
+ $('agent-back').addEventListener('click', () => { agentStep--; updateStep(); });
112
+ $('agent-recheck').addEventListener('click', async () => {
113
+ $('agent-recheck').disabled = true; $('agent-error').textContent = '';
114
+ try { await loadAgent(true); invalidateSetup(); renderProviders(); } catch (error) { $('agent-error').textContent = error.message; }
115
+ finally { $('agent-recheck').disabled = false; }
116
+ });
117
+ async function waitTurn(id, valid) {
118
+ while (valid()) {
119
+ const turn = await localAPI('/api/chat/turn/' + encodeURIComponent(id));
120
+ if (turn.state !== 'running') return turn;
121
+ await new Promise(resolve => setTimeout(resolve, 700));
122
+ }
123
+ return null;
124
+ }
125
+ $('agent-test').addEventListener('click', async () => {
126
+ if (setupTesting) return;
127
+ setupTesting = true; const revision = setupRevision; $('agent-test').disabled = true; $('agent-back').disabled = true; $('agent-error').textContent = ''; $('agent-test-status').textContent = 'Checking a response with no wallet data…';
128
+ const id = crypto.randomUUID(); setupTurn = id;
129
+ try {
130
+ const turn = await agentPost('/api/agent/test', {config: currentSetup(), idempotency_key: id});
131
+ if (revision !== setupRevision) { await agentPost('/api/chat/cancel', {id: turn.id}); return; }
132
+ const result = await waitTurn(turn.id, () => revision === setupRevision);
133
+ if (!result) return;
134
+ if (result.state !== 'succeeded') throw new Error(result.error?.message || 'Connection check stopped. Retry before saving.');
135
+ if (revision === setupRevision) { agentVerified = true; $('agent-save').disabled = false; $('agent-test-status').textContent = 'Connected. Kira received a real response.'; }
136
+ } catch (error) { await agentPost('/api/chat/cancel', {id}).catch(() => {}); if (revision === setupRevision) { $('agent-test-status').textContent = ''; $('agent-error').textContent = error.message; } }
137
+ finally { setupTesting = false; setupTurn = null; $('agent-test').disabled = false; $('agent-back').disabled = false; }
138
+ });
139
+ $('agent-dialog').addEventListener('close', () => {
140
+ invalidateSetup(); if (setupTurn) agentPost('/api/chat/cancel', {id: setupTurn}).catch(() => {});
141
+ });
142
+ $('agent-save').addEventListener('click', async () => {
143
+ if (!agentVerified) return; $('agent-save').disabled = true; $('agent-error').textContent = '';
144
+ try {
145
+ await agentPost('/api/agent/settings', currentSetup()); chatEpoch++; chatTurn = null; chatRequest = null;
146
+ await loadAgent(); $('agent-dialog').close(); $('kira-draft').focus();
147
+ } catch (error) { $('agent-error').textContent = error.message; $('agent-save').disabled = false; }
148
+ });
149
+ $('kira-draft').addEventListener('input', () => { draftRevision++; $('draft-status').textContent = 'Draft stays in memory until sent.'; renderAgent(); });
150
+ $('kira-draft').addEventListener('keydown', event => {
151
+ if (event.key === 'Enter' && !event.shiftKey && !event.isComposing && event.keyCode !== 229) { event.preventDefault(); if (!$('chat-send').disabled) $('chat-form').requestSubmit(); }
152
+ });
153
+ async function sendChat(event) {
154
+ event.preventDefault(); if (!agentState?.config || chatTurn || chatRequest) return;
155
+ const message = $('kira-draft').value.trim(); if (!message) return;
156
+ const epoch = chatEpoch, draftAtSend = draftRevision;
157
+ chatDraftRevision = draftAtSend;
158
+ chatRequest = chatRetry?.message === message && chatRetry.conversation_id === agentState.conversation_id ? chatRetry : {message, conversation_id: agentState.conversation_id, idempotency_key: crypto.randomUUID()}; chatRetry = chatRequest; renderAgent(); $('chat-error').textContent = '';
159
+ try {
160
+ const turn = await agentPost('/api/chat/send', chatRequest);
161
+ if (epoch !== chatEpoch) return;
162
+ chatTurn = turn.id; if (draftRevision === draftAtSend) $('kira-draft').value = ''; renderAgent();
163
+ const result = await waitTurn(turn.id, () => epoch === chatEpoch);
164
+ if (!result || epoch !== chatEpoch) return;
165
+ if (result.state !== 'succeeded') { if (!$('kira-draft').value && draftRevision === draftAtSend) $('kira-draft').value = message; chatRetry = null; throw new Error(result.error?.message || 'Response stopped. You can edit and send your message again.'); }
166
+ await loadAgent(); chatRetry = null; $('conversation-scroll').scrollTop = $('conversation-scroll').scrollHeight;
167
+ } catch (error) { if (epoch === chatEpoch) { $('chat-error').textContent = error.message; if (chatTurn && chatRetry) { recoverResponse(chatTurn, epoch); return; } } }
168
+ finally { if (epoch === chatEpoch && !(chatTurn && chatRetry)) { chatTurn = null; chatRequest = null; renderAgent(); if (chatRetry) loadAgent().catch(() => {}); } }
169
+ }
170
+ async function recoverResponse(id, epoch) {
171
+ try {
172
+ const result = await waitTurn(id, () => epoch === chatEpoch);
173
+ if (epoch !== chatEpoch || !result) return;
174
+ if (result.state !== 'succeeded') {
175
+ if (!$('kira-draft').value && !result.test && draftRevision === chatDraftRevision) $('kira-draft').value = chatRequest?.message || result.message || '';
176
+ $('chat-error').textContent = result.error?.message || 'Response stopped. You can edit and resend your message.';
177
+ } else $('chat-error').textContent = '';
178
+ const status = await localAPI('/api/agent');
179
+ if (epoch !== chatEpoch || chatTurn !== id) return;
180
+ agentState = status; chatTurn = null; chatRequest = null; chatRetry = null;
181
+ } catch (error) { if (epoch === chatEpoch) { $('chat-error').textContent = 'Reconnecting. ' + error.message; setTimeout(() => { if (epoch === chatEpoch && chatTurn === id) recoverResponse(id, epoch); }, 1500); } }
182
+ finally { if (epoch === chatEpoch) renderAgent(); }
183
+ }
184
+ $('chat-form').addEventListener('submit', sendChat);
185
+ $('chat-stop').addEventListener('click', async () => {
186
+ const id = chatTurn; if (!id) return;
187
+ $('chat-stop').disabled = true;
188
+ try { const result = await agentPost('/api/chat/cancel', {id}); chatEpoch++; chatTurn = null; if (result.state !== 'succeeded' && chatRequest && !$('kira-draft').value) $('kira-draft').value = chatRequest.message; chatRequest = null; chatRetry = null; await loadAgent(); $('chat-error').textContent = result.state === 'succeeded' ? '' : 'Response stopped. You can edit and resend your message.'; }
189
+ catch (error) { $('chat-error').textContent = error.message; }
190
+ finally { $('chat-stop').disabled = false; renderAgent(); }
191
+ });
192
+ $('chat-new').addEventListener('click', async () => {
193
+ if (!localSession?.controls || chatRequest && !chatTurn) return;
194
+ try { await agentPost('/api/chat/reset', {}); chatEpoch++; chatTurn = null; chatRequest = null; chatRetry = null; await loadAgent(); $('chat-error').textContent = ''; $('kira-draft').focus(); }
195
+ catch (error) { $('chat-error').textContent = error.message; }
196
+ });
197
+ async function initialAgent() {
198
+ if (agentLoading || agentReady || !localSession?.controls) return; agentLoading = true;
199
+ try { await loadAgent(); if (new URLSearchParams(location.search).get('setup') === '1') { history.replaceState(null, '', '/#/home'); await openAgent(); } }
200
+ catch { /* The normal local session polling can reconnect. */ }
201
+ finally { agentLoading = false; }
202
+ }
203
+ renderAgent(); initialAgent(); const agentStartup = setInterval(() => { initialAgent(); if (agentReady) clearInterval(agentStartup); }, 1000);