kira-wallet 0.1.4 → 0.1.5

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.
package/docs/CLI.md CHANGED
@@ -10,7 +10,7 @@ npm install -g kira-wallet
10
10
  kira setup
11
11
  ```
12
12
 
13
- The operator published npm version 0.1.3 on 2026-10-06. GitHub v0.1.3 remains
13
+ The operator published npm version 0.1.4. Version 0.1.5 is a preparation candidate. GitHub v0.1.3 remains
14
14
  a draft; the previous archive and checksum are available from the
15
15
  [0.1.2 GitHub release](https://github.com/realproject7/kira-wallet/releases/tag/v0.1.2).
16
16
  Registry publication remains an operator action. The code and original character assets use the MIT license.
@@ -42,14 +42,14 @@ address or use the browser wallet picker. Registration starts one explicit
42
42
  research job; follow it in Activity and read the saved portfolio report.
43
43
  No signature, seed phrase or private key is needed.
44
44
 
45
- Candidate 0.1.4 explains limited discovery before wallet registration and beside
46
- holdings. Missing coverage keeps the portfolio estimate partial. It is not yet
47
- published to npm. Existing snapshots require a holdings refresh after connecting
45
+ Published 0.1.4 explains limited discovery before wallet registration and beside
46
+ holdings. Missing coverage keeps the portfolio estimate partial. Existing snapshots require a holdings refresh after connecting
48
47
  an indexer; a price refresh does not discover missing tokens.
49
48
 
50
49
  Public RPC checks known assets and Mint Club. For broader ERC20 discovery,
51
- open Research connections, select Alchemy and name the private environment
52
- variable holding its key. Never paste the key into the browser. `kira doctor`
50
+ open Data provider connection, select Alchemy and follow its local key guide.
51
+ The 0.1.5 candidate uses one save for Alchemy RPC and discovery; chain overrides
52
+ remain under Advanced. Never paste the key into the browser. `kira doctor`
53
53
  checks whether the configured key is available. Configuration alone does not
54
54
  prove complete coverage; inspect the recorded results and gaps.
55
55
 
@@ -68,8 +68,12 @@ authentication. Verified adapter versions are Codex 0.158.x and Claude Code
68
68
  verified. This is a native CLI connection, with no Kira account, API-key proxy or
69
69
  hosted portfolio backend.
70
70
 
71
- In setup, choose the CLI account and an optional model ID. The blank model field
72
- uses the CLI default. Choose no automatic wallet context, one registered wallet,
71
+ In the 0.1.5 candidate, setup opens the existing Account / Permissions / Ready
72
+ modal directly. Choose Codex or Claude, then Use default model or a readable
73
+ model choice. Codex choices come from bounded native metadata; Claude offers
74
+ Sonnet, Opus and Haiku aliases. A catalog is not proof of account access.
75
+ Advanced accepts a custom identifier and preserves saved identifiers absent
76
+ from the catalog. Workspace settings reopens the same selector and permissions. Choose no automatic wallet context, one registered wallet,
73
77
  or the whole recorded portfolio. A generic response check sends no wallet data
74
78
  and must pass before these settings are saved.
75
79
 
@@ -79,7 +83,10 @@ and contract identities, timestamps, unknown prices and coverage. Credentials,
79
83
  RPC endpoints, raw provider errors and local paths are excluded. Changing the
80
84
  provider, model, context or selected wallet creates a new conversation.
81
85
 
82
- Kira-local history is opt-in and uses private `conversations/` files. With history
86
+ New setup in 0.1.5 recommends saving history and preselects it. Existing saved
87
+ true and false choices are restored exactly. Saving lets you reopen chats after
88
+ restart; it does not expand model memory or wallet access. The choice and
89
+ research permission remain editable in Workspace settings. Kira-local history uses private `conversations/` files. With history
83
90
  off, conversations and drafts stay in memory. A new server process starts a fresh
84
91
  conversation; saved files remain available locally and are not automatically
85
92
  replayed. Native CLI, administrator policy and provider data policies remain a
@@ -135,8 +142,8 @@ kira config import-env --file /absolute/private/rpc.env --key-env ALCHEMY_API_KE
135
142
  ```
136
143
 
137
144
  The import references the file instead of copying keys. It configures Alchemy
138
- RPC and portfolio discovery separately, with public fallback enabled to match
139
- the historical engine. Environment overrides the file. Missing files or keys
145
+ RPC and portfolio discovery together, preserving existing custom chain overrides
146
+ and explicit fallback preferences. New configurations allow public fallback. Environment overrides the file. Missing files or keys
140
147
  produce limited coverage; malformed configuration fails with safe errors.
141
148
  The import does not contact providers. Private settings are never served.
142
149
 
@@ -8,7 +8,8 @@ this contract. Chat interruption never cancels an engine job.
8
8
  Mutations use `{schema_version: 1, operation, input, idempotency_key}`.
9
9
  Supported operations are `wallet.add` (address, exact tag), `wallet.refresh`
10
10
  (wallet address or exact tag), `prices.refresh` (same), `wallet.setTags`
11
- (wallet, exact tags), `job.resume` and `job.cancel` (job_id).
11
+ (wallet, exact tags), `settings.rpc`, `settings.discovery`, `settings.provider`,
12
+ `job.resume` and `job.cancel` (job_id).
12
13
  Read operations are `job.read`, `job.list`, `snapshot.read`, and
13
14
  `snapshots.compare`. The portfolio projection retains its existing schema.
14
15
 
@@ -21,6 +22,17 @@ the same job without rerunning providers. Resume requeues the same job ID and
21
22
  increments its attempt on execution. Retain the key until the job is removed
22
23
  by a future explicit retention policy. Version mismatches fail closed.
23
24
 
25
+ `settings.provider` accepts exactly `{provider: "public" | "alchemy", key_env}`.
26
+ The key reference is a bounded environment variable name, never a credential.
27
+ One writer-locked atomic replacement changes RPC mode and discovery together.
28
+ Alchemy updates generated network references while preserving explicit custom
29
+ chain overrides, unrelated chains, explorer choice and public fallback policy.
30
+ A missing local key fails before replacement. Public mode keeps inactive custom
31
+ references for later use. A detected key is not a validated provider or complete
32
+ holdings result. Existing settings.rpc and settings.discovery requests remain
33
+ compatible. The browser waits for terminal settings results; retrying a lost
34
+ result checks the same accepted job instead of submitting another change.
35
+
24
36
  ## Envelope
25
37
 
26
38
  Each private `jobs/<uuid>.json` records schema_version, job_id, operation,
@@ -4,6 +4,27 @@ Kira connects the user's installed, unmodified Codex or Claude Code CLI. It does
4
4
 
5
5
  The setup flow separates account readiness, model choice, context permission, and confirmation. The conversation composer remains visible. It shows the connected CLI, automatic context scope, response state, cancellation, and a new conversation action. Enter sends; Shift+Enter adds a line; composition events never send.
6
6
 
7
+ `kira setup` opens the existing Account, Permissions and Ready modal directly
8
+ after the local session and workspace are available. Opening setup does not run
9
+ a model response check, save permissions or start wallet research. Existing
10
+ settings are loaded when setup is reopened. Closing the modal consumes that
11
+ page's setup request, so background polling does not reopen it. Ordinary
12
+ `kira start` keeps the normal workspace entry behavior.
13
+
14
+ The 0.1.5 selector defaults to Use default model. Codex uses a bounded stdio
15
+ app-server handshake and model/list request; it never starts a thread or turn.
16
+ The subprocess has an eight-second metadata deadline and 256 KB output bound.
17
+ Metadata failures leave the default and Advanced identifier entry available.
18
+ Only installed supported CLI versions are queried. Claude uses its documented
19
+ Sonnet, Opus and Haiku aliases. Every chosen model still requires a deliberate
20
+ generic response test. Vendor account and managed policies may restrict access.
21
+ Workspace settings reuses this modal for both providers and all permissions.
22
+ Fresh setup preselects local history; existing false values remain false.
23
+ History permits reopening chats, without extending model memory or scope.
24
+ No wallet context permits general chat and manual wallet management while
25
+ wallet context and research tools remain unavailable. Any effective model or
26
+ permission change starts a fresh conversation.
27
+
7
28
  Automatic context is none, one explicitly selected registered wallet, or the whole recorded portfolio. An allowlisted projection preserves exact balances, contract and chain identities, timestamps, unknown values, and coverage gaps. It excludes credentials, RPC endpoints, paths, raw errors, jobs, and provider payloads. User-entered text is also sent to the selected provider. Scope, wallet, provider, or model changes start a new conversation and never replay old messages. Only a bounded current conversation is replayed.
8
29
 
9
30
  Approved wallet context can include validated historical full-balance Mint Club
@@ -53,7 +74,7 @@ in Activity and also appear in the chat tray. Unanalysed wallets show a sidebar
53
74
  spinner and open Activity while their first holdings job is active.
54
75
 
55
76
  History is capped at 100 memory sessions and reads up to 200 validated local
56
- files. Saving remains opt-in. Memory sessions disappear when the process stops.
77
+ files. Saving is recommended for fresh setup and remains an editable choice. Memory sessions disappear when the process stops.
57
78
  A previous session can continue only with exactly the same model and context
58
79
  configuration. Reading history does not expand model access. Old saved files
59
80
  remain readable. No missing memory-only conversations are reconstructed.
@@ -65,3 +86,15 @@ The receipt is consumed only on success. This does not enable history retention.
65
86
  The Kira system voice is concise, composed and practical. It leads with findings
66
87
  and preserves evidence, unknown values and signing boundaries. Personality is
67
88
  prompt guidance; response quality still depends on the selected model.
89
+
90
+ ## 0.1.5 preparation evidence
91
+
92
+ The combined settings and model-selection tests cover catalog failures, stale
93
+ responses, custom identifiers, fresh retention defaults, saved opt-outs,
94
+ atomic provider changes, preference preservation, missing keys, cancellation
95
+ and ambiguous receipts. Desktop 1440 × 900 and mobile 390 × 844 checks used an
96
+ empty disposable workspace. Codex 0.158.0 and Claude Code 2.1.293 completed
97
+ native default-model response checks with no wallet data. The provider save
98
+ used a synthetic local key reference and made no provider research request.
99
+ Real extension approval, personal OWS passphrases and live holdings coverage
100
+ are separate operator acceptance. Published 0.1.4 snapshots are not rewritten.
package/docs/RELEASE.md CHANGED
@@ -32,7 +32,7 @@ creation now identifies Open Wallet Standard with its official locally served
32
32
  logo and explains encrypted local storage and public-account research. The
33
33
  OWS integration and wallet-operation permissions are unchanged.
34
34
 
35
- The next candidate is **0.1.4**. It makes incomplete token discovery visible
35
+ Published **0.1.4** makes incomplete token discovery visible
36
36
  before registration and beside wallet holdings, separates disabled discovery
37
37
  from missing credentials and provider failures, and offers a connection or
38
38
  holdings-refresh action. Registry failures retain bounded redacted examples
@@ -44,10 +44,19 @@ RPC failures show affected networks, retry guidance and a settings action.
44
44
  Failed candidate balance reads keep the saved result and valuation partial
45
45
  even when general token discovery succeeded.
46
46
 
47
- Version 0.1.4 requires independent review and operator registry publication.
48
- It is not yet available from the registry. The showcase HTML animation is
49
- deployed separately and requires no npm release. Keep published 0.1.2 and
50
- 0.1.3 package bytes immutable. See [discovery acceptance](KIRA_DISCOVERY_ACCEPTANCE.md).
47
+ The next candidate is **0.1.5**. It opens the existing setup modal directly,
48
+ adds readable Codex and Claude model choices, and exposes model and permission
49
+ changes in Workspace settings. New setup recommends local history; upgrades
50
+ preserve explicit opt-outs. Watching wallet methods use individual buttons.
51
+ Data provider connection offers one atomic Alchemy RPC and discovery save,
52
+ with custom overrides under Advanced. A missing key keeps prior settings.
53
+ The wallet draft survives a connection detour. Connecting still requires an
54
+ explicit holdings refresh before missing assets can appear in saved evidence.
55
+
56
+ Version 0.1.5 requires independent review and operator registry publication.
57
+ It is not yet available from the registry. Keep published 0.1.4 and earlier
58
+ package bytes immutable. The showcase remains a separate deployment.
59
+ See [discovery acceptance](KIRA_DISCOVERY_ACCEPTANCE.md).
51
60
 
52
61
  The operator published npm **0.1.2** on 2026-10-05. Its registry archive was
53
62
  downloaded and matched the reviewed GitHub archive above, including SHA-256,
@@ -69,8 +78,7 @@ The earlier unpublished 0.1.2 candidate is preserved in the ignored
69
78
  `dist/preserved-0.1.2-bad8f02/` directory. Use the current `dist/release.json`
70
79
  and its matching archive for publication; do not mix manifests from candidates.
71
80
 
72
- Keep GitHub v0.1.1, its tag and its released archive immutable. Versions 0.1.2
73
- and 0.1.3 are also published and immutable. Do not rebuild them from later documentation
81
+ Keep GitHub v0.1.1, its tag and its released archive immutable. Versions 0.1.2, 0.1.3 and 0.1.4 are also published and immutable. Do not rebuild them from later documentation
74
82
  commits. Future package changes need a new version. Release preparation does not
75
83
  publish a GitHub release, merge PR 19, promote the website or publish to npm.
76
84
  Registry publication remains operator-only. The coding agent never publishes.
@@ -85,12 +93,12 @@ python3 scripts/prepare-release.py
85
93
  ```
86
94
 
87
95
  `prepare-release.py` scans the package boundary and creates
88
- `dist/kira-wallet-0.1.4.tgz`. The ignored `dist/release.json` records its source
96
+ `dist/kira-wallet-0.1.5.tgz`. The ignored `dist/release.json` records its source
89
97
  commit, SHA-256 and member count. It never publishes. After reviewing that
90
98
  manifest, the operator's final command from the project root is:
91
99
 
92
100
  ```sh
93
- npm publish ./dist/kira-wallet-0.1.4.tgz --access public
101
+ npm publish ./dist/kira-wallet-0.1.5.tgz --access public
94
102
  ```
95
103
 
96
104
  Use the operator's npm account and complete authentication or OTP personally.
package/kira_agent.py CHANGED
@@ -251,6 +251,7 @@ class AgentStore:
251
251
  self.root = Path(root);self.path = self.root/'.kira-agent.json';self.runner=runner;self.detector=detector
252
252
  self.lock=threading.RLock();self.turns={};self.active=None;self.epoch=0;self.messages=[];self.conversation=str(uuid.uuid4());self.tested=set();self.config=None;self.workers=[];self.tool_status=None
253
253
  self._capabilities=None;self._checked=0
254
+ self._model_lock=threading.Lock();self._model_cache={}
254
255
  self.sessions={};self.started_at=datetime.now(timezone.utc).isoformat();self.updated_at=self.started_at
255
256
  if self.path.exists():
256
257
  try: self.config=config_input(json.loads(self.path.read_text()),self.root)
@@ -344,6 +345,19 @@ class AgentStore:
344
345
  self._capabilities=self.detector();self._checked=time.monotonic()
345
346
  with self.lock:return {'config':self.config,'providers':self._capabilities,'conversation_id':self.conversation,'messages':self.messages.copy(),'active_turn':self.active,'tool_status':self.tool_status}
346
347
 
348
+ def models(self, provider, recheck=False):
349
+ if provider not in PROVIDERS:raise JobError('invalid_provider','Choose Codex or Claude.')
350
+ with self._model_lock:
351
+ row=next((row for row in self.status(recheck)['providers'] if row['id']==provider),None)
352
+ if not row or not row['installed'] or not row['supported']:
353
+ return {'models':[], 'note':'Install a supported CLI to load model choices. The default model remains available.'}
354
+ cached=self._model_cache.get(provider)
355
+ if not recheck and cached and time.monotonic()-cached[0]<60:return cached[1]
356
+ from kira_models import catalog
357
+ result=catalog(provider)
358
+ self._model_cache[provider]=(time.monotonic(),result)
359
+ return result
360
+
347
361
  def fingerprint(self,config):return hashlib.sha256(json.dumps(config,sort_keys=True).encode()).hexdigest()
348
362
 
349
363
  def stop(self):
package/kira_cli.py CHANGED
@@ -232,10 +232,9 @@ def main():
232
232
  if args.command=='config':
233
233
  file=Path(args.file).expanduser().resolve()
234
234
  if not file.is_file():raise ValueError('Secret environment file does not exist.')
235
- from wallet import ALCHEMY
236
- cfg['secret_env_file']=str(file);cfg['discovery']={'provider':'alchemy','key_env':args.key_env,'explorers':True}
237
- cfg['rpc']={'mode':'custom','allow_public_fallback':True,
238
- 'chains':{str(cid):{'url_env':args.key_env,'alchemy_network':name} for cid,name in ALCHEMY.items()}}
235
+ from kira_config import provider_config
236
+ cfg=provider_config(cfg,'alchemy',args.key_env)
237
+ cfg['secret_env_file']=str(file);cfg['discovery']['explorers']=True
239
238
  elif args.command=='discovery':cfg['discovery']={'provider':args.provider,'key_env':args.key_env,'explorers':args.explorers}
240
239
  elif args.setting=='public':cfg['rpc']['mode']='public'
241
240
  else:
package/kira_config.py CHANGED
@@ -19,6 +19,19 @@ def default_config():
19
19
  return {'schema_version':1,'rpc':{'mode':'public','allow_public_fallback':True,'chains':{}},
20
20
  'discovery':{'provider':'none','key_env':'ALCHEMY_API_KEY','explorers':False}}
21
21
 
22
+ def provider_config(config, provider, key_env):
23
+ """One settings transition. Preserve explicit fallback and custom overrides."""
24
+ result={**config,'rpc':{**config['rpc'],'chains':{cid:dict(row) for cid,row in config['rpc']['chains'].items()}},
25
+ 'discovery':{**config['discovery'],'provider':'alchemy' if provider=='alchemy' else 'none','key_env':key_env}}
26
+ result['rpc']['mode']='custom' if provider=='alchemy' else 'public'
27
+ if provider=='alchemy':
28
+ from wallet import ALCHEMY
29
+ for cid,network in ALCHEMY.items():
30
+ previous=result['rpc']['chains'].get(str(cid))
31
+ if not previous or previous.get('alchemy_network'):
32
+ result['rpc']['chains'][str(cid)]={'url_env':key_env,'alchemy_network':network}
33
+ return result
34
+
22
35
  def load_config():
23
36
  path=config_path()
24
37
  if not path.exists():return default_config()
package/kira_jobs.py CHANGED
@@ -19,7 +19,7 @@ import uuid
19
19
 
20
20
  ASSETS = Path(__file__).resolve().parent
21
21
  State = Literal['queued', 'running', 'interrupted', 'succeeded', 'partial', 'failed', 'cancelled']
22
- Operation = Literal['wallet.add','wallet.refresh','prices.refresh','wallet.setTags','settings.rpc','settings.discovery']
22
+ Operation = Literal['wallet.add','wallet.refresh','prices.refresh','wallet.setTags','settings.rpc','settings.discovery','settings.provider']
23
23
  TERMINAL = {'succeeded', 'partial', 'failed', 'cancelled', 'interrupted'}
24
24
  ADDRESS = re.compile(r'0x[0-9a-fA-F]{40}')
25
25
 
@@ -124,10 +124,10 @@ class JobStore:
124
124
  if not isinstance(job,dict) or type(job.get('schema_version')) is not int or job.get('schema_version') != 1: raise JobError('unsupported_version', 'Unsupported persisted job version.')
125
125
  required={'schema_version','job_id','operation','input','state','attempt','sequence','result','previous_result','key_hash','request_hash','created_at','updated_at','cancel_requested','checkpoint','stage','events','errors','chains'}
126
126
  optional={'raw_hash','control_receipts','control_raw','provider_configuration','started_at'}
127
- if required-set(job) or set(job)-required-optional or job['job_id']!=job_id or job['checkpoint']!='snapshots/jobs/'+job_id or not isinstance(job['operation'],str) or job['operation'] not in {'wallet.add','wallet.refresh','prices.refresh','wallet.setTags','settings.rpc','settings.discovery'} or not isinstance(job['state'],str) or job['state'] not in TERMINAL|{'queued','running'} or any(type(job[k]) is not int or job[k]<0 for k in ('attempt','sequence')) or type(job['cancel_requested']) is not bool or not isinstance(job['input'],dict) or any(not isinstance(job[k],list) for k in ('events','errors')) or not isinstance(job['chains'],dict):
127
+ if required-set(job) or set(job)-required-optional or job['job_id']!=job_id or job['checkpoint']!='snapshots/jobs/'+job_id or not isinstance(job['operation'],str) or job['operation'] not in {'wallet.add','wallet.refresh','prices.refresh','wallet.setTags','settings.rpc','settings.discovery','settings.provider'} or not isinstance(job['state'],str) or job['state'] not in TERMINAL|{'queued','running'} or any(type(job[k]) is not int or job[k]<0 for k in ('attempt','sequence')) or type(job['cancel_requested']) is not bool or not isinstance(job['input'],dict) or any(not isinstance(job[k],list) for k in ('events','errors')) or not isinstance(job['chains'],dict):
128
128
  raise JobError('invalid_envelope','Persisted job validation failed. Preserve the file for recovery.')
129
129
  names={'wallet.add':['address','tag'],'wallet.refresh':['wallet'],'prices.refresh':['wallet'],'wallet.setTags':['wallet','tags'],
130
- 'settings.rpc':['mode','chains','allow_public_fallback'],'settings.discovery':['provider','key_env']}[job['operation']]
130
+ 'settings.rpc':['mode','chains','allow_public_fallback'],'settings.discovery':['provider','key_env'],'settings.provider':['provider','key_env']}[job['operation']]
131
131
  fields(job['input'],names)
132
132
  wallet=job['input'].get('wallet') or job['input'].get('address')
133
133
  if wallet is not None and (not isinstance(wallet,str) or not ADDRESS.fullmatch(wallet)):raise JobError('invalid_envelope','Persisted wallet identity is invalid.')
@@ -154,6 +154,11 @@ class JobStore:
154
154
  return matches[0]
155
155
 
156
156
  def normalize(self, operation, value):
157
+ if operation == 'settings.provider':
158
+ fields(value,['provider','key_env'])
159
+ if value['provider'] not in ('public','alchemy') or not isinstance(value['key_env'],str) or not re.fullmatch(r'[A-Z_][A-Z0-9_]{0,99}',value['key_env']):
160
+ raise JobError('invalid_settings','Choose a data provider and a local credential reference.')
161
+ return value.copy()
157
162
  if operation == 'settings.rpc':
158
163
  fields(value,['mode','chains','allow_public_fallback'])
159
164
  if value['mode'] not in ('public','custom') or type(value['allow_public_fallback']) is not bool or not isinstance(value['chains'],list) or len(value['chains'])>30:
@@ -403,7 +408,12 @@ class JobStore:
403
408
  previous=config['rpc']['chains'].get(key,{})
404
409
  if row['url_env']==previous.get('url_env') and previous.get('alchemy_network'):row['alchemy_network']=previous['alchemy_network']
405
410
  config['rpc']=updated
406
- else:config['discovery']={**job['input'],'explorers':config['discovery'].get('explorers',False)}
411
+ elif job['operation']=='settings.discovery':config['discovery']={**job['input'],'explorers':config['discovery'].get('explorers',False)}
412
+ else:
413
+ from kira_config import provider_config,secret_values
414
+ config=provider_config(config,**job['input'])
415
+ if job['input']['provider']=='alchemy' and not secret_values(config).get(job['input']['key_env']):
416
+ raise JobError('connection_key_missing','Kira cannot find the local Alchemy key. Follow the connection guide, then retry. Existing settings are unchanged.')
407
417
  atomic(config_path(),config)
408
418
  result={'status':'completed','setting':job['operation']};atomic(self.jobs/'results'/(job['job_id']+'.json'),result);return result
409
419
  env = {**os.environ, 'KIRA_DATA_DIR': str(self.root), 'KIRA_JOB_ID': job['job_id'], 'KIRA_JOB_SNAPSHOT': job['checkpoint'], 'KIRA_ANALYSIS_FD': str(writer.fileno()), 'PYTHONUNBUFFERED': '1'}
package/kira_models.py ADDED
@@ -0,0 +1,86 @@
1
+ """Bounded native model metadata. No inference, threads or credential copying."""
2
+ import json
3
+ import os
4
+ import re
5
+ import selectors
6
+ import shutil
7
+ import signal
8
+ import subprocess
9
+ import tempfile
10
+ import time
11
+
12
+ MODEL = re.compile(r'[a-zA-Z0-9._:/-]{1,100}')
13
+
14
+ def clean_catalog(rows):
15
+ result=[]; seen=set()
16
+ if not isinstance(rows,list):return result
17
+ for row in rows[:100]:
18
+ if not isinstance(row,dict) or row.get('hidden'):continue
19
+ model=row.get('model') or row.get('id')
20
+ if not isinstance(model,str) or not MODEL.fullmatch(model) or model in seen:continue
21
+ name=row.get('displayName') or model
22
+ if not isinstance(name,str):name=model
23
+ name=' '.join(name.split())[:100]
24
+ result.append({'id':model,'name':name or model});seen.add(model)
25
+ if len(result)==40:break
26
+ return result
27
+
28
+ def codex_catalog(path, env, *, timeout=8):
29
+ from kira_agent import CODEX_DISABLED
30
+ argv=[path,'app-server','--listen','stdio://','-c','analytics.enabled=false',
31
+ '-c','history.persistence="none"','-c','mcp_servers={}', '-c','model_provider="openai"']
32
+ for feature in CODEX_DISABLED:argv+=['-c',f'features.{feature}=false']
33
+ with tempfile.TemporaryDirectory(prefix='kira-models-') as cwd:
34
+ child=subprocess.Popen(argv,stdin=subprocess.PIPE,stdout=subprocess.PIPE,stderr=subprocess.DEVNULL,
35
+ cwd=cwd,env=env,start_new_session=True)
36
+ selector=selectors.DefaultSelector();selector.register(child.stdout,selectors.EVENT_READ)
37
+ def send(value):
38
+ child.stdin.write((json.dumps(value)+'\n').encode());child.stdin.flush()
39
+ try:
40
+ send({'id':1,'method':'initialize','params':{'clientInfo':{'name':'kira_model_picker','version':'0.1.5'}}})
41
+ deadline=time.monotonic()+timeout;buffer=b'';received=0;requested=False
42
+ while time.monotonic()<deadline:
43
+ for key,_ in selector.select(min(.2,max(0,deadline-time.monotonic()))):
44
+ chunk=os.read(key.fileobj.fileno(),65536)
45
+ if not chunk:raise ValueError('Catalog ended.')
46
+ received+=len(chunk)
47
+ if received>262144:raise ValueError('Catalog too large.')
48
+ buffer+=chunk
49
+ while b'\n' in buffer:
50
+ line,buffer=buffer.split(b'\n',1)
51
+ try:message=json.loads(line)
52
+ except ValueError:continue
53
+ if not isinstance(message,dict):continue
54
+ if message.get('id')==1 and not requested:
55
+ if 'error' in message:raise ValueError('Initialization failed.')
56
+ requested=True
57
+ send({'method':'initialized'})
58
+ send({'id':2,'method':'model/list','params':{'limit':100,'includeHidden':False}})
59
+ elif message.get('id')==2 and requested:
60
+ if 'error' in message:raise ValueError('Catalog unavailable.')
61
+ result=message.get('result')
62
+ return clean_catalog(result.get('data')) if isinstance(result,dict) else []
63
+ raise TimeoutError('Catalog timed out.')
64
+ finally:
65
+ selector.close()
66
+ try:os.killpg(child.pid,signal.SIGTERM)
67
+ except ProcessLookupError:pass
68
+ try:child.wait(timeout=2)
69
+ except subprocess.TimeoutExpired:
70
+ try:os.killpg(child.pid,signal.SIGKILL)
71
+ except ProcessLookupError:pass
72
+ child.wait()
73
+ child.stdin.close();child.stdout.close()
74
+
75
+ def catalog(provider):
76
+ from kira_agent import native_env
77
+ if provider=='claude':
78
+ return {'models':[{'id':'sonnet','name':'Sonnet'}, {'id':'opus','name':'Opus'}, {'id':'haiku','name':'Haiku'}],
79
+ 'note':'Claude CLI model choices. Check a response to verify access for your account.'}
80
+ if provider!='codex':raise ValueError('Unsupported provider.')
81
+ try:
82
+ path=shutil.which('codex')
83
+ models=codex_catalog(path,native_env()) if path else []
84
+ except (OSError,ValueError,TypeError,TimeoutError):models=[]
85
+ return {'models':models,'note':'CLI catalog choices. Check a response to verify account access.' if models else
86
+ 'Model choices are unavailable. Use the default model, recheck, or enter an ID under Advanced.'}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kira-wallet",
3
- "version": "0.1.4",
3
+ "version": "0.1.5",
4
4
  "description": "Manage your wallets with your own AI. Track tokens, research markets and refresh holdings in a local workspace.",
5
5
  "license": "MIT",
6
6
  "private": false,
@@ -56,7 +56,8 @@
56
56
  "kira_ows.py",
57
57
  "ows_bridge.cjs",
58
58
  "docs/KIRA_LIVE_QA.md",
59
- "docs/KIRA_DISCOVERY_ACCEPTANCE.md"
59
+ "docs/KIRA_DISCOVERY_ACCEPTANCE.md",
60
+ "kira_models.py"
60
61
  ],
61
62
  "scripts": {
62
63
  "test": "python3 -m unittest discover -s . -p 'test_*.py' && python3 -m unittest discover -s viewer -p 'test_*.py' && node test-onchain.cjs && node --check viewer/static/app.js && node --check viewer/static/jobs.js && node test-workspace.cjs && node --check viewer/static/workspace.js && node --check viewer/static/workspace-model.js && node test-watching.cjs && node test-watching-ui.cjs && node --check viewer/static/watching.js && node --check viewer/static/watching-model.js && node --check viewer/static/agent.js && node test-agent-ui.cjs && node test-markdown.cjs && node --check viewer/static/report.js && node test-report-ui.cjs && node test-holdings-ui.cjs && node test-rpc-fallback.cjs && node test-ows.cjs && node test-ows-ui.cjs && node --check viewer/static/ows.js && node --check viewer/static/chat-workspace.js && node --check viewer/static/selects.js && node --check site/demo.js"
@@ -54,7 +54,8 @@
54
54
  "prices.refresh",
55
55
  "wallet.setTags",
56
56
  "settings.rpc",
57
- "settings.discovery"
57
+ "settings.discovery",
58
+ "settings.provider"
58
59
  ]
59
60
  },
60
61
  "result": {
@@ -347,6 +347,49 @@
347
347
  "idempotency_key"
348
348
  ],
349
349
  "additionalProperties": false
350
+ },
351
+ {
352
+ "type": "object",
353
+ "properties": {
354
+ "schema_version": {
355
+ "const": 1
356
+ },
357
+ "operation": {
358
+ "const": "settings.provider"
359
+ },
360
+ "input": {
361
+ "type": "object",
362
+ "properties": {
363
+ "provider": {
364
+ "enum": [
365
+ "public",
366
+ "alchemy"
367
+ ]
368
+ },
369
+ "key_env": {
370
+ "type": "string",
371
+ "pattern": "^[A-Z_][A-Z0-9_]{0,99}$"
372
+ }
373
+ },
374
+ "required": [
375
+ "provider",
376
+ "key_env"
377
+ ],
378
+ "additionalProperties": false
379
+ },
380
+ "idempotency_key": {
381
+ "type": "string",
382
+ "minLength": 8,
383
+ "maxLength": 200
384
+ }
385
+ },
386
+ "required": [
387
+ "schema_version",
388
+ "operation",
389
+ "input",
390
+ "idempotency_key"
391
+ ],
392
+ "additionalProperties": false
350
393
  }
351
394
  ]
352
395
  }
@@ -6,8 +6,10 @@ def readiness():
6
6
  config = load_config()
7
7
  discovery = config['discovery']
8
8
  configured = discovery['provider'] != 'none'
9
- available = configured and bool(secret_values(config).get(discovery['key_env']))
9
+ local_available = bool(secret_values(config).get(discovery['key_env']))
10
+ available = configured and local_available
10
11
  return {'rpc_mode': config['rpc']['mode'],
11
12
  'discovery_provider': discovery['provider'],
12
13
  'discovery_configured': configured,
13
- 'discovery_key_available': bool(available)}
14
+ 'discovery_key_available': bool(available),
15
+ 'local_key_available': local_available}
package/viewer/server.py CHANGED
@@ -100,7 +100,7 @@ class Handler(BaseHTTPRequestHandler):
100
100
  self.json({'instance':os.environ.get('KIRA_INSTANCE_ID'),'read_only':not getattr(self.server,'controls',False),'controls':getattr(self.server,'controls',False)});return
101
101
  if path=='/api/session':
102
102
  self.json({'controls':getattr(self.server,'controls',False),'token':self.server.session_token if getattr(self.server,'controls',False) else None});return
103
- if path.startswith('/api/jobs') or path.startswith('/api/chat/turn/') or path in ('/api/agent','/api/chat','/api/chat/history','/api/onboarding','/api/snapshots','/api/snapshot','/api/compare','/api/settings','/api/ows'):
103
+ if path.startswith('/api/jobs') or path.startswith('/api/chat/turn/') or path in ('/api/agent','/api/agent/models','/api/chat','/api/chat/history','/api/onboarding','/api/snapshots','/api/snapshot','/api/compare','/api/settings','/api/ows'):
104
104
  if not self.authenticated():return
105
105
  query=parse_qs(urlparse(self.path).query)
106
106
  if path in ('/api/agent','/api/chat'):
@@ -108,6 +108,8 @@ class Handler(BaseHTTPRequestHandler):
108
108
  self.json({'config':None,'providers':[],'conversation_id':None,'messages':[],'active_turn':None})
109
109
  else:self.json(self.server.agent.status(query.get('recheck')==['1']))
110
110
  elif path.startswith('/api/chat/turn/'):self.json(self.server.agent.read(path.removeprefix('/api/chat/turn/')))
111
+ elif path=='/api/agent/models':
112
+ self.json({'models':[],'note':'Model choices are available in a personal workspace.'} if json.loads((ROOT/'wallets.json').read_text()).get('demo') else self.server.agent.models(query.get('provider',[''])[0],query.get('recheck')==['1']))
111
113
  elif path=='/api/chat/history':self.json(self.server.agent.history(query.get('id',[None])[0]))
112
114
  elif path=='/api/jobs':self.json(self.server.jobs.list())
113
115
  elif path.startswith('/api/jobs/'):self.json(self.server.jobs.get(path.removeprefix('/api/jobs/')))
@@ -1,7 +1,8 @@
1
1
  'use strict';
2
2
  let agentState = null, agentProvider = 'codex', agentStep = 1, setupRevision = 0, agentVerified = false;
3
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;
4
+ let chatSignature = '', agentReady = false, chatDraftRevision = 0, modelCatalogRevision = 0, modelOpenRevision = 0;
5
+ let modelChoices = [], modelChoicesProvider = null;
5
6
  const scopeNames = {none: 'No wallet context', wallet: 'One approved wallet', portfolio: 'Whole portfolio'};
6
7
  const questionStarters = {
7
8
  liquidity: 'Which tokens in my wallets may have been sitting idle, and what could I receive if I sold my full balance? Show each token’s chain, contract, quantity, price and timestamp, pool pair and venue, spot value, and any recorded full-balance output. Use actual transfer-history evidence for inactivity; if it is unavailable, say so. Keep sale proceeds, price impact, fees and gas unknown when there is no current executable quote.',
@@ -21,7 +22,7 @@ function scopeLabel(config) {
21
22
  }
22
23
  function renderAgent() {
23
24
  const config = agentState?.config;
24
- $('chat-model-label').textContent = config ? (config.provider === 'codex' ? 'Codex' : 'Claude') + ' · ' + (config.model || 'CLI default') : 'Connect a model';
25
+ $('chat-model-label').textContent = config ? (config.provider === 'codex' ? 'Codex' : 'Claude') + ' · ' + modelName(config.model, config.provider) : 'Connect a model';
25
26
  $('chat-setup').classList.toggle('connected', Boolean(config));
26
27
  $('chat-context').textContent = scopeLabel(config);
27
28
  $('chat-choose-context').hidden = !config || !state?.wallets.length || !localSession?.controls;
@@ -44,6 +45,29 @@ function renderAgent() {
44
45
  scroller.scrollTop=follow?scroller.scrollHeight:previousTop;
45
46
  }
46
47
  }
48
+ function modelName(id, provider = agentProvider) { return (provider === modelChoicesProvider ? modelChoices.find(row => row.id === id)?.name : null) || id || 'Default model'; }
49
+ function renderModelChoice() {
50
+ const model = $('agent-model').value.trim();
51
+ $('agent-model-choice').innerHTML = '<option value="">Use default model</option>' + modelChoices.map(row => '<option value="' + escapeHTML(row.id) + '">' + escapeHTML(row.name) + '</option>').join('') + '<option value="__custom__">Custom model ID…</option>';
52
+ $('agent-model-choice').value = !model ? '' : modelChoices.some(row => row.id === model) ? model : '__custom__';
53
+ if ($('agent-model-choice').value === '__custom__') $('agent-model-advanced').open = true;
54
+ if (typeof KiraSelect !== 'undefined') KiraSelect.refresh();
55
+ }
56
+ async function loadModelChoices(recheck = false) {
57
+ const revision = ++modelCatalogRevision, provider = agentProvider;
58
+ modelChoices = []; modelChoicesProvider = provider; renderModelChoice();
59
+ $('agent-model-note').textContent = 'Loading model choices. You can continue with the default model.';
60
+ try {
61
+ const result = await localAPI('/api/agent/models?provider=' + provider + (recheck ? '&recheck=1' : ''));
62
+ if (revision !== modelCatalogRevision || provider !== agentProvider || !$('agent-dialog').open) return;
63
+ modelChoices = (Array.isArray(result.models) ? result.models : []).filter(row => row && typeof row.id === 'string' && typeof row.name === 'string');
64
+ $('agent-model-note').textContent = result.note || 'Check a response to verify access for your account.';
65
+ } catch {
66
+ if (revision !== modelCatalogRevision || !$('agent-dialog').open) return;
67
+ $('agent-model-note').textContent = 'Model choices are unavailable. Use the default model, recheck, or enter an ID under Advanced.';
68
+ }
69
+ renderModelChoice();
70
+ }
47
71
  function renderProviders() {
48
72
  $('agent-providers').innerHTML = (agentState?.providers || []).map(row => {
49
73
  const ready = row.installed && row.supported && row.logged_in;
@@ -65,9 +89,10 @@ function updateStep() {
65
89
  const selected = agentState?.providers.find(p => p.id === agentProvider);
66
90
  $('agent-next').disabled = agentStep === 1 ? !(selected?.installed && selected.supported && selected.logged_in) : !$('agent-trust').checked || (currentSetup().scope === 'wallet' && !$('agent-wallet').value);
67
91
  $('agent-wallet-field').hidden = currentSetup().scope !== 'wallet';
92
+ $('agent-tools').disabled = currentSetup().scope === 'none';
68
93
  if (agentStep === 3) {
69
94
  const config = currentSetup();
70
- $('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>';
95
+ $('agent-review').innerHTML = '<dt>Account</dt><dd>' + escapeHTML(selected?.name || '') + '</dd><dt>Model</dt><dd>' + escapeHTML(modelName(config.model)) + '</dd><dt>Context</dt><dd>' + escapeHTML(scopeLabel(config)) + '</dd><dt>History</dt><dd>' + (config.retain_history ? 'Saved on this computer' : 'Memory only') + '</dd>';
71
96
  }
72
97
  }
73
98
  async function loadAgent(recheck = false) {
@@ -98,36 +123,47 @@ async function openAgent() {
98
123
  if (state?.demo) { toast('The sample workspace does not contact model services. Run kira setup for your personal workspace.'); return; }
99
124
  if (!localSession?.controls) { toast('Start your local workspace with kira setup to enable connection settings.'); return; }
100
125
  $('agent-error').textContent = ''; $('agent-providers').innerHTML = '<p>Checking installed accounts…</p>';
101
- $('agent-dialog').showModal(); agentStep = 1; invalidateSetup();
126
+ const opening = ++modelOpenRevision;
127
+ $('agent-dialog').showModal(); agentStep = 1; invalidateSetup(); updateStep();
128
+ $('agent-next').disabled = true; $('agent-model').disabled = true; $('agent-model-choice').disabled = true;
129
+ if (typeof KiraSelect !== 'undefined') KiraSelect.refresh();
102
130
  try {
103
131
  await loadAgent();
132
+ if (opening !== modelOpenRevision || !$('agent-dialog').open) return;
104
133
  const config = agentState.config;
105
134
  agentProvider = config?.provider || agentState.providers.find(p => p.installed && p.supported && p.logged_in)?.id || 'codex';
106
135
  $('agent-model').value = config?.model || '';
107
136
  document.querySelector('[name="agent-scope"][value="' + (config?.scope || 'portfolio') + '"]').checked = true;
108
137
  $('agent-wallet').innerHTML = '<option value="">Choose a wallet</option>' + (state?.wallets || []).map(w => `<option value="${escapeHTML(w.key)}">${escapeHTML(w.name)}</option>`).join('');
109
- $('agent-wallet').value = config?.wallet || ''; $('agent-retain').checked = config?.retain_history || false; $('agent-trust').checked = config?.trust_native_cli || false; $('agent-tools').checked = config ? config.wallet_tools === true : true;
110
- renderProviders();
111
- } catch (error) { $('agent-error').textContent = error.message; }
138
+ $('agent-wallet').value = config?.wallet || ''; $('agent-retain').checked = config ? config.retain_history === true : true; $('agent-trust').checked = config?.trust_native_cli || false; $('agent-tools').checked = config ? config.wallet_tools === true : true;
139
+ renderProviders(); loadModelChoices();
140
+ } catch (error) { if (opening === modelOpenRevision) $('agent-error').textContent = error.message; }
141
+ finally { if (opening === modelOpenRevision) { $('agent-model').disabled = false; $('agent-model-choice').disabled = false; if (typeof KiraSelect !== 'undefined') KiraSelect.refresh(); } }
112
142
  }
113
143
  $('chat-setup').addEventListener('click', openAgent);
114
144
  $('chat-choose-context').addEventListener('click', openAgent);
115
- $('settings-agent').addEventListener('click', () => { $('settings-dialog').close(); openAgent(); });
145
+ $('settings-agent').addEventListener('click', () => { $('workspace-settings-dialog').close(); openAgent(); });
116
146
  $('agent-providers').addEventListener('click', event => {
117
147
  const button = event.target.closest('[data-provider]'); if (!button || setupTesting) return;
118
- if (agentProvider !== button.dataset.provider) { agentProvider = button.dataset.provider; $('agent-model').value = ''; invalidateSetup(); renderProviders(); }
148
+ if (agentProvider !== button.dataset.provider) { agentProvider = button.dataset.provider; $('agent-model').value = ''; invalidateSetup(); renderProviders(); loadModelChoices(); }
119
149
  });
120
150
  $('agent-cli-help').addEventListener('click', async event => {
121
151
  const button = event.target.closest('[data-copy-cli]'); if (!button) return;
122
152
  try { await navigator.clipboard.writeText(button.dataset.copyCli); button.textContent = 'Copied'; } catch { toast('Select and copy the command in your terminal.'); }
123
153
  });
154
+ $('agent-model-choice').addEventListener('change', () => {
155
+ const value = $('agent-model-choice').value;
156
+ if (value === '__custom__') { $('agent-model-advanced').open = true; $('agent-model').focus(); }
157
+ else { $('agent-model').value = value; invalidateSetup(); updateStep(); }
158
+ });
159
+ $('agent-model').addEventListener('input', renderModelChoice);
124
160
  for (const id of ['agent-model', 'agent-wallet', 'agent-retain', 'agent-trust', 'agent-tools']) $(id).addEventListener('input', () => { invalidateSetup(); updateStep(); });
125
161
  document.querySelectorAll('[name="agent-scope"]').forEach(input => input.addEventListener('change', () => { invalidateSetup(); updateStep(); }));
126
162
  $('agent-next').addEventListener('click', () => { if (!$('agent-next').disabled) { agentStep++; updateStep(); $('agent-dialog').scrollTop = 0; } });
127
163
  $('agent-back').addEventListener('click', () => { agentStep--; updateStep(); });
128
164
  $('agent-recheck').addEventListener('click', async () => {
129
165
  $('agent-recheck').disabled = true; $('agent-error').textContent = '';
130
- try { await loadAgent(true); invalidateSetup(); renderProviders(); } catch (error) { $('agent-error').textContent = error.message; }
166
+ try { await loadAgent(true); invalidateSetup(); renderProviders(); loadModelChoices(true); } catch (error) { $('agent-error').textContent = error.message; }
131
167
  finally { $('agent-recheck').disabled = false; }
132
168
  });
133
169
  async function waitTurn(id, valid) {
@@ -154,7 +190,12 @@ $('agent-test').addEventListener('click', async () => {
154
190
  finally { setupTesting = false; setupTurn = null; $('agent-test').disabled = false; $('agent-back').disabled = false; }
155
191
  });
156
192
  $('agent-dialog').addEventListener('close', () => {
157
- invalidateSetup(); if (setupTurn) agentPost('/api/chat/cancel', {id: setupTurn}).catch(() => {});
193
+ const query = new URLSearchParams(location.search);
194
+ if (query.get('setup') === '1') {
195
+ query.delete('setup');
196
+ history.replaceState(null, '', location.pathname + (query.size ? '?' + query : '') + location.hash);
197
+ }
198
+ modelOpenRevision++; modelCatalogRevision++; invalidateSetup(); if (setupTurn) agentPost('/api/chat/cancel', {id: setupTurn}).catch(() => {});
158
199
  });
159
200
  $('agent-save').addEventListener('click', async () => {
160
201
  if (!agentVerified) return; $('agent-save').disabled = true; $('agent-error').textContent = '';
@@ -221,9 +262,19 @@ $('chat-new').addEventListener('click', async () => {
221
262
  catch (error) { $('chat-error').textContent = error.message; }
222
263
  });
223
264
  async function initialAgent() {
224
- if (agentLoading || agentReady || !localSession?.controls) return; agentLoading = true;
225
- try { await loadAgent(); if (new URLSearchParams(location.search).get('setup') === '1') { history.replaceState(null, '', '/#/home'); await loadSetupReadiness(); renderSetupPath(); } }
265
+ const setupRequested = new URLSearchParams(location.search).get('setup') === '1';
266
+ if (agentLoading || !localSession?.controls || (agentReady && !setupRequested) || (setupRequested && !state)) return; agentLoading = true;
267
+ try {
268
+ await loadAgent();
269
+ if (setupRequested && new URLSearchParams(location.search).get('setup') === '1') {
270
+ history.replaceState(null, '', '/#/home'); readRoute();
271
+ if (!$('agent-dialog').open) await openAgent();
272
+ }
273
+ }
226
274
  catch { /* The normal local session polling can reconnect. */ }
227
275
  finally { agentLoading = false; }
228
276
  }
229
- renderAgent(); initialAgent(); const agentStartup = setInterval(() => { initialAgent(); if (agentReady) clearInterval(agentStartup); }, 1000);
277
+ renderAgent(); initialAgent(); const agentStartup = setInterval(() => {
278
+ initialAgent();
279
+ if (agentReady && new URLSearchParams(location.search).get('setup') !== '1') clearInterval(agentStartup);
280
+ }, 1000);
@@ -153,7 +153,7 @@ function renderDiscoveryCoverage(w) {
153
153
  $('discovery-notice-text').textContent=notice.message;
154
154
  const controls=typeof localSession!=='undefined'&&localSession?.controls===true&&state?.demo!==true;
155
155
  $('discovery-settings').hidden=!controls||!(notice.action==='settings'||notice.settings);
156
- $('discovery-settings').textContent=notice.settingsLabel||'Connect token discovery';
156
+ $('discovery-settings').textContent=notice.settingsLabel||'Connect data provider';
157
157
  $('discovery-refresh').hidden=!controls||notice.action!=='refresh';
158
158
  $('discovery-refresh').textContent=notice.refreshLabel||'Refresh holdings';
159
159
  $('discovery-refresh').disabled=$('refresh-wallet').disabled;
@@ -50,7 +50,7 @@
50
50
  <div id="wallet-actions" class="wallet-actions" hidden><button id="refresh-wallet" class="quiet-button" type="button"><svg class="ui-icon" viewBox="0 0 24 24" fill="none" aria-hidden="true"><path d="M3 11a9 9 0 1 1 2.6 7M3 5v6h6"/></svg>Refresh holdings</button><button id="refresh-prices" class="quiet-button" type="button"><svg class="ui-icon" viewBox="0 0 24 24" fill="none" aria-hidden="true"><path d="M4 17l5-5 4 3 7-10M15 5h5v5"/></svg>Refresh prices</button><button id="edit-tags" class="quiet-button" type="button"><svg class="ui-icon" viewBox="0 0 24 24" fill="none" aria-hidden="true"><path d="M4 20l4-1L20 7l-3-3L5 16zM14 7l3 3"/></svg>Edit names</button><button id="view-history" class="quiet-button" type="button"><svg class="ui-icon" viewBox="0 0 24 24" fill="none" aria-hidden="true"><path d="M5 4h14v16H5zM8 8h8M8 12h8M8 16h5"/></svg>Saved analyses</button></div>
51
51
  <section class="wallet-heading"><div><div class="eyebrow">WALLET OVERVIEW</div><h1 id="wallet-name">Your wallets</h1><div class="wallet-address"><span id="address"></span><button id="copy-address" class="icon-button" aria-label="Copy wallet address" title="Copy wallet address"><svg viewBox="0 0 24 24" fill="none" aria-hidden="true"><rect x="8" y="8" width="11" height="12" rx="2"/><path d="M15 8V5a2 2 0 0 0-2-2H5a2 2 0 0 0-2 2v8a2 2 0 0 0 2 2h3"/></svg></button><span id="wallet-tags"></span></div></div><a id="report-link" class="text-link" target="_blank" rel="noreferrer">Research notes <span aria-hidden="true">↗</span></a></section>
52
52
  <section class="portfolio-summary" aria-label="Portfolio summary"><div class="valuation"><div class="summary-label">Estimated portfolio value</div><div id="total-value" class="total-value">—</div><div id="valuation-note" class="valuation-note">Loading the latest analysis</div></div><div class="summary-stat"><span class="summary-label">Assets</span><strong id="asset-count">—</strong><span id="unpriced-count" class="stat-note"></span></div><div class="summary-stat"><span class="summary-label">Networks</span><strong id="chain-count">—</strong><span id="coverage-note" class="stat-note"></span></div></section>
53
- <section id="discovery-notice" class="discovery-notice" aria-labelledby="discovery-notice-title" hidden><strong id="discovery-notice-title"></strong><p id="discovery-notice-text" role="status"></p><div class="discovery-actions"><button id="discovery-refresh" class="quiet-button" type="button" hidden>Refresh holdings</button><button id="discovery-settings" class="quiet-button" type="button" hidden>Connect token discovery</button></div><details><summary>Network coverage details</summary><p id="discovery-network-details"></p></details></section>
53
+ <section id="discovery-notice" class="discovery-notice" aria-labelledby="discovery-notice-title" hidden><strong id="discovery-notice-title"></strong><p id="discovery-notice-text" role="status"></p><div class="discovery-actions"><button id="discovery-refresh" class="quiet-button" type="button" hidden>Refresh holdings</button><button id="discovery-settings" class="quiet-button" type="button" hidden>Connect data provider</button></div><details><summary>Network coverage details</summary><p id="discovery-network-details"></p></details></section>
54
54
  <section class="holdings" aria-labelledby="holdings-title"><div class="holdings-toolbar"><div><h2 id="holdings-title">Holdings <span id="visible-count"></span></h2></div><div class="filters"><label class="chain-select"><span class="sr-only">Filter by chain</span><select id="chain-filter"><option value="all">All chains</option></select><svg viewBox="0 0 16 16" aria-hidden="true"><path d="m4 6 4 4 4-4"/></svg></label><div class="segment" role="group" aria-label="Minimum token value"><button data-min="0" aria-pressed="true">All</button><button data-min="5" aria-pressed="false">≥ $5</button><button data-min="10" aria-pressed="false">≥ $10</button></div></div></div>
55
55
  <div id="holdings-filter-note" class="holdings-filter-note" role="status" hidden></div><div id="asset-groups"></div>
56
56
  <div id="empty" class="empty" hidden><div class="empty-mark" aria-hidden="true">∅</div><h3>No assets in this view</h3><p>Choose another chain or lower the minimum value.</p></div>
@@ -101,7 +101,7 @@
101
101
  <form id="wallet-form">
102
102
  <div class="dialog-heading"><img src="/kira-logo.png" width="42" height="42" alt=""><h2 id="wallet-dialog-title">Add a Watching wallet</h2><button type="button" data-close-dialog aria-label="Close dialog"><svg class="ui-icon" viewBox="0 0 24 24" fill="none" aria-hidden="true" focusable="false"><path d="m6 6 12 12M6 18 18 6"/></svg></button></div>
103
103
  <p id="wallet-intro">Follow a public address. Your keys stay in your wallet.</p>
104
- <div id="wallet-entry-modes" class="wallet-entry-modes" role="group" aria-label="Choose address source"><button type="button" id="wallet-manual-mode" aria-pressed="true">Enter address</button><button type="button" id="wallet-browser-mode" aria-pressed="false">Browser wallet</button><button type="button" id="wallet-ows-mode">Local OWS wallet</button></div>
104
+ <div id="wallet-entry-modes" class="wallet-entry-modes" role="group" aria-label="Choose address source"><button type="button" id="wallet-manual-mode" class="quiet-button" aria-pressed="true">Enter address</button><button type="button" id="wallet-browser-mode" class="quiet-button" aria-pressed="false">Browser wallet</button><button type="button" id="wallet-ows-mode" class="quiet-button">Local OWS wallet</button></div>
105
105
  <p id="wallet-demo-note" class="wallet-demo-note" hidden>Sample mode: explore this form with a public address. Connecting and research are available in a personal workspace.</p>
106
106
  <section id="wallet-browser-panel" class="wallet-browser-panel" aria-label="Browser wallet connection" hidden>
107
107
  <h3 id="wallet-providers-title">Choose a browser wallet</h3>
@@ -121,7 +121,7 @@
121
121
  <label>Wallet name<input id="new-wallet-tag" required maxlength="200" placeholder="My research wallet" autocomplete="off"></label>
122
122
  <section id="wallet-preview" class="wallet-preview" aria-label="Wallet registration preview" hidden><h3>Ready to watch</h3><dl><dt>Public address</dt><dd id="wallet-preview-address"></dd><dt>Exact wallet name</dt><dd id="wallet-preview-name"></dd></dl></section>
123
123
  <p id="wallet-existing" class="wallet-existing" hidden>This Watching wallet is already saved. <a id="wallet-existing-link">Open the saved wallet</a> to keep its current names.</p>
124
- <section id="wallet-discovery-warning" class="discovery-notice" aria-label="Token discovery connection"><strong id="wallet-discovery-warning-title">Checking token discovery</strong><p id="wallet-discovery-warning-text">An indexer is needed to find other ERC20 holdings. Public RPC alone checks native balances and Mint Club assets.</p><button id="wallet-discovery-settings" class="quiet-button" type="button">Connect token discovery</button></section>
124
+ <section id="wallet-discovery-warning" class="discovery-notice" aria-label="Token discovery connection"><strong id="wallet-discovery-warning-title">Checking token discovery</strong><p id="wallet-discovery-warning-text">An indexer is needed to find other ERC20 holdings. Public RPC alone checks native balances and Mint Club assets.</p><button id="wallet-discovery-settings" class="quiet-button" type="button">Connect data provider</button></section>
125
125
  <p class="form-note">Research can consume provider allowance. No signature is requested.</p>
126
126
  <button id="wallet-register" class="primary-button" type="submit" disabled>Add and research</button>
127
127
  </div>
@@ -141,13 +141,36 @@
141
141
  <p id="ows-error" class="form-error" role="alert"></p>
142
142
  </dialog>
143
143
  <dialog id="tags-dialog" class="research-dialog" aria-labelledby="tags-title"><form id="tags-form"><div class="dialog-heading"><h2 id="tags-title">Wallet names</h2><button type="button" data-close-dialog aria-label="Close dialog"><svg class="ui-icon" viewBox="0 0 24 24" fill="none" aria-hidden="true" focusable="false"><path d="m6 6 12 12M6 18 18 6"/></svg></button></div><label>One exact name per line<textarea id="tag-values" required rows="4"></textarea></label><p class="form-note">The first name is shown in navigation. Saved analyses retain their original names.</p><p id="tags-error" class="form-error" role="alert"></p><button class="primary-button" type="submit">Save names</button></form></dialog>
144
- <dialog id="settings-dialog" class="research-dialog" aria-labelledby="settings-title"><div class="dialog-heading"><h2 id="settings-title">Research connections</h2><button type="button" data-close-dialog aria-label="Close dialog"><svg class="ui-icon" viewBox="0 0 24 24" fill="none" aria-hidden="true" focusable="false"><path d="m6 6 12 12M6 18 18 6"/></svg></button></div><form id="rpc-form"><label>RPC reads<select id="rpc-mode"><option value="public">Public RPC · no API key</option><option value="custom">Custom RPC references</option></select></label><p class="form-note">RPC reads and token discovery are separate. A connection is not proof of complete holdings.</p><label>Custom references, one per line<textarea id="rpc-references" rows="3" placeholder="8453 KIRA_BASE_RPC"></textarea></label><p class="form-note">Enter chain ID and environment variable name. Keep endpoint URLs and keys outside the browser. Restart the app after changing its environment.</p><p class="form-note">Public RPC is used automatically when a custom endpoint is missing, unavailable or on the wrong chain.</p><details><summary>Advanced RPC preference</summary><label class="checkbox-label"><input id="rpc-custom-only" type="checkbox">Require custom RPC only</label></details><button class="quiet-button" type="submit">Save RPC settings</button></form><form id="discovery-form"><label>Broader token discovery<select id="discovery-provider"><option value="none">Limited · no indexer</option><option value="alchemy">Alchemy · your credential</option></select></label><label>Credential environment variable<input id="discovery-key" value="ALCHEMY_API_KEY" pattern="[A-Z_][A-Z0-9_]*"></label><button class="quiet-button" type="submit">Save discovery settings</button></form><p id="settings-error" class="form-error" role="alert"></p><div class="assistant-gate"><strong>Your model connection</strong><p>Use your installed CLI account and choose what Kira sends.</p><button id="settings-agent" class="quiet-button">Manage model and permissions</button></div></dialog>
144
+ <dialog id="workspace-settings-dialog" class="research-dialog" aria-labelledby="workspace-settings-title">
145
+ <div class="dialog-heading"><h2 id="workspace-settings-title">Workspace settings</h2><button type="button" data-close-dialog aria-label="Close settings">×</button></div>
146
+ <div class="settings-section"><h3>Model and permissions</h3><p id="workspace-model-summary" class="form-note"></p><button id="settings-agent" class="quiet-button" type="button">Change model and permissions</button></div>
147
+ <div class="settings-section"><h3>Data provider connection</h3><p id="workspace-data-summary" class="form-note"></p><button id="settings-data" class="quiet-button" type="button">Manage data connection</button></div>
148
+ </dialog>
149
+ <dialog id="settings-dialog" class="research-dialog" aria-labelledby="settings-title">
150
+ <div class="dialog-heading"><h2 id="settings-title">Data provider connection</h2><button type="button" data-close-dialog aria-label="Close connection settings">×</button></div>
151
+ <form id="provider-form">
152
+ <label>Connection<select id="data-provider"><option value="public">Basic · public connection</option><option value="alchemy">Alchemy · broader token coverage</option></select></label>
153
+ <p id="provider-description" class="form-note"></p><p id="provider-key-status" class="form-note" role="status"></p>
154
+ <div id="provider-key-guide" class="connection-guide" hidden><strong>Connect your Alchemy account locally</strong><p>Create a key in the <a href="https://dashboard.alchemy.com/" target="_blank" rel="noreferrer">Alchemy dashboard</a>. Put it in a private environment file, then run <code>kira config import-env --file /path/to/private.env</code> in your terminal. Reopen these settings after setup. Keep the key outside this browser.</p></div>
155
+ <button id="provider-save" class="primary-button" type="submit">Save connection</button>
156
+ </form>
157
+ <p id="provider-status" class="form-note" role="status"></p><p id="settings-error" class="form-error" role="alert"></p>
158
+ <details id="connection-advanced"><summary>Advanced connection settings</summary>
159
+ <label>Alchemy key reference<input id="discovery-key" value="ALCHEMY_API_KEY" pattern="[A-Z_][A-Z0-9_]*" maxlength="100"></label><p class="form-note">Environment variable name only. The connection save above uses this reference.</p>
160
+ <form id="rpc-form"><label>RPC preference<select id="rpc-mode"><option value="public">Public RPC</option><option value="custom">Custom RPC references</option></select></label>
161
+ <div id="rpc-custom-fields" hidden><label>Custom references, one per line<textarea id="rpc-references" rows="3" placeholder="8453 KIRA_BASE_RPC"></textarea></label><p class="form-note">Chain ID and environment variable name. Keep URLs and keys in your private environment file.</p><label class="checkbox-label"><input id="rpc-custom-only" type="checkbox">Require custom RPC only</label></div>
162
+ <p class="form-note">Custom RPC reads known assets. An arbitrary endpoint may not find other ERC20 holdings. Public fallback applies when enabled. RPC preferences do not change token discovery.</p><button class="quiet-button" type="submit">Save RPC preference</button>
163
+ </form>
164
+ </details>
165
+ <p class="form-note">Saving a connection does not register a wallet or start research. Refresh holdings to use it in an existing wallet. Saved analyses keep their original coverage.</p>
166
+ <button id="settings-done" class="quiet-button" type="button">Done</button>
167
+ </dialog>
145
168
  <dialog id="history-dialog" class="research-dialog history-dialog" aria-labelledby="history-title"><div class="dialog-heading"><h2 id="history-title">Saved analyses</h2><button type="button" data-close-dialog aria-label="Close dialog"><svg class="ui-icon" viewBox="0 0 24 24" fill="none" aria-hidden="true" focusable="false"><path d="m6 6 12 12M6 18 18 6"/></svg></button></div><div id="history-list"></div><form id="compare-form" hidden><label>Earlier analysis<select id="compare-before"></select></label><label>Later analysis<select id="compare-after"></select></label><button class="quiet-button" type="submit">Compare evidence</button></form><div id="comparison-result" class="comparison-result"></div><p id="history-error" class="form-error" role="alert"></p></dialog>
146
169
  <dialog id="agent-dialog" class="research-dialog agent-dialog" aria-labelledby="agent-title">
147
170
  <div class="dialog-heading"><div><span class="eyebrow">MAKE KIRA YOURS</span><h2 id="agent-title">Connect your account.</h2></div><button id="agent-close" type="button" data-close-dialog aria-label="Close setup"><svg class="ui-icon" viewBox="0 0 24 24" fill="none" aria-hidden="true"><path d="m6 6 12 12M6 18 18 6"/></svg></button></div>
148
171
  <ol class="setup-progress" aria-label="Setup progress"><li data-step="1" aria-current="step"><span>1</span> Account</li><li data-step="2"><span>2</span> Permissions</li><li data-step="3"><span>3</span> Ready</li></ol>
149
- <section id="agent-step-1"><p class="setup-intro">Bring the model account you already use. Kira connects your installed CLI, with its existing login and subscription limits.</p><div id="agent-providers" class="provider-grid" role="group" aria-label="Choose your CLI account"></div><div id="agent-cli-help" class="cli-help"></div><button id="agent-recheck" class="text-button" type="button">Recheck installed CLIs</button><label class="model-field">Model ID <span>optional</span><input id="agent-model" placeholder="CLI default" maxlength="100" autocomplete="off"></label><p class="form-note">Leave blank to use the CLI default, or enter a model your account supports. A response check verifies access.</p></section>
150
- <section id="agent-step-2" hidden><h3>What can Kira send?</h3><p class="setup-intro">Your portfolio stays on this computer. When you send a message, its text and the context below go through your CLI to its model service.</p><fieldset class="scope-choices"><legend class="sr-only">Automatic context</legend><label><input type="radio" name="agent-scope" value="none"><span><strong>No wallet context</strong><small>Start with general questions. Only your conversation text is sent.</small></span></label><label><input type="radio" name="agent-scope" value="wallet"><span><strong>One wallet</strong><small>Recorded holdings, addresses, names, timestamps and coverage for one wallet.</small></span></label><label><input type="radio" name="agent-scope" value="portfolio" checked><span><strong>Whole portfolio</strong><small>The same recorded facts for every registered wallet.</small></span></label></fieldset><label id="agent-wallet-field" hidden>Approved wallet<select id="agent-wallet"></select></label><label class="permission-choice"><input id="agent-retain" type="checkbox"><span><strong>Save conversations on this computer</strong><small>Off means Kira keeps this conversation in memory. Provider and native CLI data policies remain separate.</small></span></label><label class="permission-choice"><input id="agent-tools" type="checkbox" checked><span><strong>Let Kira research my wallets</strong><small>Read saved analyses and start holdings or price updates for the wallets selected above. Jobs appear in Activity. Your keys stay outside the agent.</small></span></label><label class="permission-choice native-trust"><input id="agent-trust" type="checkbox"><span><strong>I trust my installed CLI and its account policy</strong><small>Kira limits the context it supplies and uses scoped wallet research tools. Native CLI and administrator policy remain trusted components. No passwords or API keys are entered here.</small></span></label><p class="form-note">Changing the account, model or context starts a fresh conversation. Research tools can refresh approved wallets. Chat cannot sign or trade.</p></section>
172
+ <section id="agent-step-1"><p class="setup-intro">Bring the model account you already use. Kira connects your installed CLI, with its existing login and subscription limits.</p><div id="agent-providers" class="provider-grid" role="group" aria-label="Choose your CLI account"></div><div id="agent-cli-help" class="cli-help"></div><button id="agent-recheck" class="text-button" type="button">Recheck installed CLIs</button><label class="model-field">Model<select id="agent-model-choice"><option value="">Use default model</option></select></label><p id="agent-model-note" class="form-note" role="status">Use the default model to get started. A response check verifies account access.</p><details id="agent-model-advanced"><summary>Advanced model choice</summary><label>Custom model ID<input id="agent-model" maxlength="100" autocomplete="off" placeholder="Optional model identifier"></label><p class="form-note">Use an identifier supported by your installed CLI. Leave blank for its default model.</p></details></section>
173
+ <section id="agent-step-2" hidden><h3>What can Kira send?</h3><p class="setup-intro">Your portfolio stays on this computer. When you send a message, its text and the context below go through your CLI to its model service.</p><fieldset class="scope-choices"><legend class="sr-only">Automatic context</legend><label><input type="radio" name="agent-scope" value="none"><span><strong>No wallet context</strong><small>General chat and manual wallet management stay available. Wallet AI context and research are off. Change this later in Workspace settings.</small></span></label><label><input type="radio" name="agent-scope" value="wallet"><span><strong>One wallet</strong><small>Recorded holdings, addresses, names, timestamps and coverage for one wallet.</small></span></label><label><input type="radio" name="agent-scope" value="portfolio" checked><span><strong>Whole portfolio</strong><small>The same recorded facts for every registered wallet.</small></span></label></fieldset><label id="agent-wallet-field" hidden>Approved wallet<select id="agent-wallet"></select></label><label class="permission-choice"><input id="agent-retain" type="checkbox" checked><span><strong>Save chat history on this computer (recommended)</strong><small>Reopen your chats after restarting Kira. Off keeps new chats only until Kira stops. Saving does not expand model memory or wallet access. Provider and native CLI data policies remain separate.</small></span></label><label class="permission-choice"><input id="agent-tools" type="checkbox" checked><span><strong>Let Kira research my wallets</strong><small>Read saved analyses and start holdings or price updates for the wallets selected above. Jobs appear in Activity. Requires wallet context above. You can change this later in Workspace settings. Your keys stay outside the agent.</small></span></label><label class="permission-choice native-trust"><input id="agent-trust" type="checkbox"><span><strong>I trust my installed CLI and its account policy</strong><small>Kira limits the context it supplies and uses scoped wallet research tools. Native CLI and administrator policy remain trusted components. No passwords or API keys are entered here.</small></span></label><p class="form-note">Changing any model or permission setting starts a fresh conversation. You can change all these choices in Workspace settings. Research tools can refresh approved wallets. Chat cannot sign or trade.</p></section>
151
174
  <section id="agent-step-3" hidden><div class="ready-art"><img src="/kira-logo.png" width="64" height="64" alt=""></div><h3>One last hello.</h3><p class="setup-intro">Check a real response before you start. This check sends a generic greeting with no wallet data.</p><dl id="agent-review" class="agent-review"></dl><button id="agent-test" class="quiet-button" type="button">Check connection</button><p id="agent-test-status" role="status"></p><p class="form-note">Messages use your chosen CLI account. Its subscription limits and data policies apply.</p></section>
152
175
  <p id="agent-error" class="form-error" role="alert"></p><div class="setup-footer"><button id="agent-back" class="quiet-button" type="button" hidden>Back</button><button id="agent-next" class="primary-button" type="button">Continue</button><button id="agent-save" class="primary-button" type="button" hidden disabled>Start talking with Kira</button></div>
153
176
  </dialog>
@@ -2,7 +2,7 @@
2
2
  let localSession=null, jobList=[], jobsSignature='', localPolling=false;
3
3
  let discoverySettingsRevision='';
4
4
  const pendingActions=new Set();
5
- const jobLabels={'wallet.add':'Wallet research','wallet.refresh':'Holdings refresh','prices.refresh':'Price refresh','wallet.setTags':'Wallet names','settings.rpc':'RPC settings','settings.discovery':'Token discovery'};
5
+ const jobLabels={'wallet.add':'Wallet research','wallet.refresh':'Holdings refresh','prices.refresh':'Price refresh','wallet.setTags':'Wallet names','settings.rpc':'RPC settings','settings.discovery':'Token discovery','settings.provider':'Data provider connection'};
6
6
  const stageLabels={queued:'Waiting to start',starting:'Opening saved evidence',registered:'Wallet registered',discovery:'Token discovery',chain:'Checking on-chain holdings',dex_discovery:'Checking markets',token_images:'Preparing token artwork',published:'Evidence saved',failed:'Research stopped',stopped:'Research stopped',interrupted:'Ready for recovery'};
7
7
  async function localAPI(path,options={}){
8
8
  const response=await fetch(path,{cache:'no-store',...options,headers:{'X-Kira-Session':localSession?.token||'',...options.headers},signal:options.signal||AbortSignal.timeout(12000)});
@@ -16,7 +16,7 @@ async function submitOperation(operation,input){
16
16
  const key=sessionStorage.getItem(storageKey)||crypto.randomUUID();sessionStorage.setItem(storageKey,key);
17
17
  try{
18
18
  const result=await localAPI('/api/operations',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({schema_version:1,operation,input,idempotency_key:key})});
19
- sessionStorage.removeItem(storageKey);toast('Saved '+(jobLabels[operation]||'job').toLowerCase());await pollJobs();return result;
19
+ sessionStorage.removeItem(storageKey);toast((operation.startsWith('settings.')?'Queued ':'Saved ')+(jobLabels[operation]||'job').toLowerCase());await pollJobs();return result;
20
20
  }finally{pendingActions.delete(fingerprint);}
21
21
  }
22
22
  function refreshControlState(){
@@ -76,7 +76,7 @@ async function pollJobs(){
76
76
  if(!localSession){const response=await fetch('/api/session',{cache:'no-store',signal:AbortSignal.timeout(5000)});if(!response.ok)return;localSession=await response.json();}
77
77
  refreshControlState();if(!localSession.controls){renderJobs();return;}
78
78
  const jobs=await localAPI('/api/jobs');jobList=jobs;const reconnected=!jobsConnected;jobsConnected=true;lastJobsPoll=new Date().toISOString();
79
- const setting=jobs.filter(j=>j.operation==='settings.discovery'&&j.state==='succeeded').map(j=>j.job_id+':'+j.updated_at).sort().join(',');
79
+ const setting=jobs.filter(j=>j.operation.startsWith('settings.')&&j.state==='succeeded').map(j=>j.job_id+':'+j.updated_at).sort().join(',');
80
80
  if(setting!==discoverySettingsRevision){discoverySettingsRevision=setting;if(typeof loadSetupReadiness==='function')await loadSetupReadiness();}
81
81
  const signature=JSON.stringify(jobs)+JSON.stringify(jobs.map(j=>KiraView.jobPresentation(j).quiet));if(signature!==jobsSignature||reconnected){jobsSignature=signature;renderJobs();}else{$('jobs-freshness').textContent='Checked '+stamp(lastJobsPoll);}
82
82
  refreshControlState();
@@ -108,12 +108,109 @@ $('refresh-prices').addEventListener('click',async()=>{try{await submitOperation
108
108
  $('edit-tags').addEventListener('click',()=>{if(!currentWallet())return;$('tags-dialog').dataset.wallet=selectedWallet;$('tag-values').value=currentWallet().tags.join('\n');$('tags-error').textContent='';$('tags-dialog').showModal();});
109
109
  formAction($('tags-form'),$('tags-error'),async()=>{await submitOperation('wallet.setTags',{wallet:$('tags-dialog').dataset.wallet,tags:$('tag-values').value.split('\n').filter(t=>t.length)});$('tags-dialog').close();});
110
110
  $('jobs-list').addEventListener('click',async event=>{const button=event.target.closest('[data-job-action]');if(!button)return;button.disabled=true;try{await submitOperation('job.'+button.dataset.jobAction,{job_id:button.dataset.jobId});}catch(error){button.disabled=false;toast(error.message);}});
111
- $('open-settings').addEventListener('click',async()=>{try{const config=await localAPI('/api/settings');$('rpc-mode').value=config.rpc.mode;$('rpc-custom-only').checked=!config.rpc.allow_public_fallback;$('rpc-references').value=Object.entries(config.rpc.chains).map(([chain,row])=>chain+' '+row.url_env).join('\n');$('discovery-provider').value=config.discovery.provider;$('discovery-key').value=config.discovery.key_env;$('settings-error').textContent='';if(typeof KiraSelect!=='undefined')KiraSelect.refresh();$('settings-dialog').showModal();if(typeof loadSetupReadiness==='function')loadSetupReadiness();}catch(error){toast(error.message);}});
112
- $('discovery-settings').addEventListener('click',()=>$('open-settings').click());
113
- $('wallet-discovery-settings').addEventListener('click',()=>{$('wallet-dialog').close();$('open-settings').click();});
111
+ let connectionConfig = null, connectionReadiness = null, connectionRevision = 0, workspaceSettingsRevision = 0, pendingSetting = null, settingSaving = false, settingSaved = false, settingFailed = false;
112
+ function connectionSummary(config) {
113
+ return config.discovery.provider === 'alchemy' ? 'Alchemy selected. Coverage is confirmed by holdings research.' : config.rpc.mode === 'custom' ? 'Custom RPC selected. Broader token discovery is off.' : 'Basic public connection. Broader token discovery is off.';
114
+ }
115
+ function renderConnectionForm() {
116
+ const alchemy = $('data-provider').value === 'alchemy';
117
+ const available = connectionReadiness?.local_key_available === true && $('discovery-key').value === connectionConfig?.discovery.key_env;
118
+ $('provider-description').textContent = alchemy ? 'One connection enables Alchemy RPC reads and broader ERC20 discovery on supported networks. Custom chain overrides stay in place.' : 'Use public RPC for native and known assets. Other ERC20 holdings may be missing.';
119
+ $('provider-key-status').textContent = !alchemy ? '' : available ? 'Local key found. Research results will confirm access and chain coverage.' : 'A local Alchemy key is required before this connection can be saved.';
120
+ $('provider-key-guide').hidden = !alchemy || available;
121
+ $('rpc-custom-fields').hidden = $('rpc-mode').value !== 'custom';
122
+ if (typeof KiraSelect !== 'undefined') KiraSelect.refresh();
123
+ }
124
+ async function openWorkspaceSettings() {
125
+ if (!localSession?.controls) return;
126
+ const revision = ++workspaceSettingsRevision;
127
+ if (!$('workspace-settings-dialog').open) $('workspace-settings-dialog').showModal();
128
+ $('workspace-model-summary').textContent = 'Checking saved model and permissions…';
129
+ $('workspace-data-summary').textContent = 'Checking the saved connection…';
130
+ const [modelResult, dataResult] = await Promise.allSettled([localAPI('/api/agent'), localAPI('/api/settings')]);
131
+ if (revision !== workspaceSettingsRevision || !$('workspace-settings-dialog').open) return;
132
+ if (modelResult.status === 'fulfilled') {
133
+ const config = modelResult.value.config;
134
+ $('workspace-model-summary').textContent = config ? (config.provider === 'codex' ? 'Codex' : 'Claude') + ' · ' + (config.model || 'Default model') + ' · ' + (config.scope === 'none' ? 'No wallet context' : config.scope === 'wallet' ? 'One wallet' : 'Whole portfolio') + ' · History ' + (config.retain_history ? 'saved locally' : 'off') + ' · Research ' + (config.wallet_tools && config.scope !== 'none' ? 'on' : 'off') : 'Connect Codex or Claude. Choose wallet context, local history and research access.';
135
+ } else $('workspace-model-summary').textContent = 'Could not check saved model settings. Reopen to retry.';
136
+ $('workspace-data-summary').textContent = dataResult.status === 'fulfilled' ? connectionSummary(dataResult.value) : 'Could not check the data connection. Reopen to retry.';
137
+
138
+ }
139
+ async function openDataConnections(advanced = false) {
140
+ if (!localSession?.controls) return;
141
+ const revision = ++connectionRevision;
142
+ $('settings-error').textContent = ''; $('provider-status').textContent = pendingSetting ? 'A saved request is pending. Save again to check its result.' : '';
143
+ $('provider-save').disabled = true; $('settings-dialog').showModal();
144
+ $('settings-done').textContent = $('wallet-dialog').open ? 'Back to wallet' : 'Done';
145
+ $('connection-advanced').open = advanced;
146
+ try {
147
+ const [config, readiness] = await Promise.all([localAPI('/api/settings'), localAPI('/api/onboarding')]);
148
+ if (revision !== connectionRevision || !$('settings-dialog').open) return;
149
+ connectionConfig = config; connectionReadiness = readiness;
150
+ $('data-provider').value = config.discovery.provider === 'alchemy' ? 'alchemy' : 'public';
151
+ $('rpc-mode').value = config.rpc.mode; $('rpc-custom-only').checked = !config.rpc.allow_public_fallback;
152
+ $('rpc-references').value = Object.entries(config.rpc.chains).map(([chain,row]) => chain+' '+row.url_env).join('\n');
153
+ $('discovery-key').value = config.discovery.key_env;
154
+ $('provider-status').textContent = pendingSetting ? 'A saved request is pending. Save again to check its result.' : connectionSummary(config);
155
+ renderConnectionForm(); $('provider-save').disabled = false;
156
+ } catch (error) { if (revision === connectionRevision) $('settings-error').textContent = error.message; }
157
+ }
158
+ async function saveConnectionSetting(operation, input) {
159
+ if (settingSaving) throw new Error('A settings request is already being checked. Wait for its result.');
160
+ settingSaving = true; settingSaved = false; settingFailed = false;
161
+ try {
162
+ await finishConnectionSetting(operation,input);
163
+ } catch (error) {
164
+ $('provider-status').textContent = settingSaved ? 'Connection saved. Status could not be refreshed; retry checks the same request.' : pendingSetting ? 'The saved request is pending. Retry checks the same request.' : settingFailed ? 'Connection settings were not saved.' : 'Could not confirm the settings request. Check Activity or retry the same choices.';
165
+ throw settingSaved ? new Error('Settings were saved. Reopen or retry to refresh the connection status.') : error;
166
+ }
167
+ finally { settingSaving = false; }
168
+ }
169
+ async function finishConnectionSetting(operation, input) {
170
+ const signature = JSON.stringify({operation,input});
171
+ if (!pendingSetting) {
172
+ const accepted = await submitOperation(operation, input);
173
+ pendingSetting = {id:accepted.job_id,signature};
174
+ }
175
+ const pending = pendingSetting;
176
+ $('provider-status').textContent = 'Connection settings are queued. Waiting for the saved result…';
177
+ for (let attempt = 0; attempt < 20; attempt++) {
178
+ const result = await localAPI('/api/jobs/' + encodeURIComponent(pending.id));
179
+ if (['succeeded','failed','cancelled','interrupted','partial'].includes(result.state)) {
180
+ if (result.state !== 'succeeded') { settingFailed = true; if (pendingSetting === pending) pendingSetting = null; throw new Error(result.errors?.[0]?.message || 'Connection settings were not saved. Review Activity and retry.'); }
181
+ if (pending.signature !== signature) { if (pendingSetting === pending) pendingSetting = null; throw new Error('The previous settings request finished. Review your new choices and save again.'); }
182
+ settingSaved = true;
183
+ $('provider-status').textContent = 'Connection saved. Refresh holdings to check the new coverage.';
184
+ if (typeof loadSetupReadiness === 'function') await loadSetupReadiness();
185
+ connectionConfig = await localAPI('/api/settings'); connectionReadiness = await localAPI('/api/onboarding');
186
+ renderConnectionForm(); if (pendingSetting === pending) pendingSetting = null; return;
187
+ }
188
+ await new Promise(resolve => setTimeout(resolve, 400));
189
+ }
190
+ throw new Error('Settings are still queued. Check Activity or save again to check the same request.');
191
+ }
192
+ $('open-settings').addEventListener('click',openWorkspaceSettings);
193
+ $('settings-data').addEventListener('click',()=>openDataConnections());
194
+ $('discovery-settings').addEventListener('click',()=>openDataConnections($('discovery-settings').textContent === 'Review RPC settings'));
195
+ $('wallet-discovery-settings').addEventListener('click',()=>openDataConnections());
196
+ $('settings-done').addEventListener('click',()=>$('settings-dialog').close());
197
+ $('workspace-settings-dialog').addEventListener('close',()=>{workspaceSettingsRevision++;});
198
+ $('settings-dialog').addEventListener('close',()=>{connectionRevision++; if ($('workspace-settings-dialog').open) openWorkspaceSettings();});
199
+ $('data-provider').addEventListener('change',renderConnectionForm);
200
+ $('rpc-mode').addEventListener('change',renderConnectionForm);
201
+ $('discovery-key').addEventListener('input',renderConnectionForm);
114
202
  $('discovery-refresh').addEventListener('click',()=>$('refresh-wallet').click());
115
- formAction($('rpc-form'),$('settings-error'),async()=>{const lines=$('rpc-references').value.split('\n').filter(s=>s.trim());const chains=lines.map(line=>{const parts=line.trim().split(/\s+/);if(parts.length!==2)throw new Error('Enter a chain ID and environment variable name on each line.');return {chain_id:Number(parts[0]),url_env:parts[1]};});await submitOperation('settings.rpc',{mode:$('rpc-mode').value,allow_public_fallback:!$('rpc-custom-only').checked,chains});});
116
- formAction($('discovery-form'),$('settings-error'),async()=>{await submitOperation('settings.discovery',{provider:$('discovery-provider').value,key_env:$('discovery-key').value});if(typeof loadSetupReadiness==='function')await loadSetupReadiness();});
203
+ formAction($('provider-form'),$('settings-error'),async()=>{
204
+ const input = {provider:$('data-provider').value,key_env:$('discovery-key').value.trim()};
205
+ $('data-provider').disabled = true; $('discovery-key').disabled = true;
206
+ try { await saveConnectionSetting('settings.provider',input); }
207
+ finally { $('data-provider').disabled = false; $('discovery-key').disabled = false; if (typeof KiraSelect !== 'undefined') KiraSelect.refresh(); }
208
+ });
209
+ formAction($('rpc-form'),$('settings-error'),async()=>{
210
+ const lines=$('rpc-references').value.split('\n').filter(s=>s.trim());
211
+ const chains=lines.map(line=>{const parts=line.trim().split(/\s+/);if(parts.length!==2)throw new Error('Enter a chain ID and environment variable name on each line.');return {chain_id:Number(parts[0]),url_env:parts[1]};});
212
+ await saveConnectionSetting('settings.rpc',{mode:$('rpc-mode').value,allow_public_fallback:!$('rpc-custom-only').checked,chains});
213
+ });
117
214
  $('view-history').addEventListener('click',async()=>{try{const rows=await localAPI('/api/snapshots?wallet='+encodeURIComponent(selectedWallet));$('history-list').innerHTML=rows.map(row=>`<article class="history-row"><strong>${stamp(row.compiled_at)}</strong><span>${escapeHTML(row.status==='completed'?'Saved':'Saved with coverage gaps')}</span><small>${escapeHTML(row.snapshot_id)}</small></article>`).join('')||'<p>No analysis has been published yet.</p>';$('compare-form').hidden=rows.length<2;for(const id of ['compare-before','compare-after'])$(id).innerHTML=rows.map(row=>`<option value="${escapeHTML(row.snapshot_id)}">${stamp(row.compiled_at)} · ${escapeHTML(row.snapshot_id.split('/').at(-1))}</option>`).join('');$('compare-after').selectedIndex=Math.max(0,rows.length-1);$('comparison-result').textContent='';$('history-error').textContent='';$('history-dialog').showModal();}catch(error){toast(error.message);}});
118
215
  formAction($('compare-form'),$('history-error'),async()=>{const comparison=await localAPI('/api/compare?before='+encodeURIComponent($('compare-before').value)+'&after='+encodeURIComponent($('compare-after').value));$('comparison-result').innerHTML=`<p>${escapeHTML(comparison.note)}</p><p>Coverage observations ${comparison.coverage_changed?'changed':'unchanged'} · native price references ${comparison.price_references_changed?'changed':'unchanged'}</p><div class="table-wrap"><table><thead><tr><th>Token / chain</th><th>Earlier balance</th><th>Later balance</th><th>Change</th></tr></thead><tbody>${comparison.positions.map(p=>`<tr><td>${escapeHTML(p.symbol||shortAddress(p.address))} · ${p.chain_id}<small>${escapeHTML(p.presence.replaceAll('_',' '))}${p.price_references_changed?' · price references changed':''}${p.valuation_method_changed?' · method changed':''}</small></td><td>${escapeHTML(p.before_balance??'Unknown')}</td><td>${escapeHTML(p.after_balance??'Unknown')}</td><td>${escapeHTML(p.balance_delta??'Unknown')}</td></tr>`).join('')}</tbody></table></div>`;});
119
216
  window.addEventListener('hashchange',()=>{refreshControlState();renderJobs();});
@@ -17,11 +17,11 @@ function renderSetupPath() {
17
17
  $('home-empty').hidden=wallet||!$('setup-path').hidden;
18
18
  $('setup-path').innerHTML='<details '+(!wallet||!discovery?'open':'')+'><summary>'+ (wallet?'Finish your setup':'Set up your first wallet')+'</summary><div class="section-heading"><h2>'+ (wallet?'Finish your setup':'Start with one wallet')+'</h2><span class="section-note">Your keys stay yours</span></div><ol class="setup-checklist">'+
19
19
  '<li><span class="setup-check">'+(wallet?'✓':'1')+'</span><div><strong>Add a public wallet</strong><p>'+(wallet?'Saved locally.':'Paste an EVM address or choose a browser wallet. No signature or seed phrase.')+'</p></div><button class="quiet-button" id="setup-wallet" type="button">'+(wallet?'Add another':'Add wallet')+'</button></li>'+
20
- '<li><span class="setup-check">'+(discovery?'✓':'2')+'</span><div><strong>Connect token discovery</strong><p>'+(!setupReadiness?(setupUnavailable?'Could not check the connection. Retrying…':'Checking the discovery connection…'):discovery?'Indexer key detected. Actual chain coverage is confirmed by research results.':configured?'Indexer selected, but its key is unavailable. Use a private environment reference.':'Public RPC checks known assets. Connect an indexer for broader ERC20 discovery, or continue with limited coverage.')+'</p></div><button class="quiet-button" id="setup-discovery" type="button">Review connection</button></li>'+
20
+ '<li><span class="setup-check">'+(discovery?'✓':'2')+'</span><div><strong>Data provider connection <small>optional</small></strong><p>'+(!setupReadiness?(setupUnavailable?'Could not check the connection. Retrying…':'Checking the data connection…'):discovery?'Local Alchemy key found. Actual chain coverage is confirmed by research results.':configured?'Alchemy selected, but its local key is unavailable. Review the connection guide.':'Public RPC checks known assets. Connect Alchemy for broader ERC20 discovery, or continue with limited coverage.')+'</p></div><button class="quiet-button" id="setup-discovery" type="button">Review connection</button></li>'+
21
21
  '<li><span class="setup-check">'+(analysed?'✓':'3')+'</span><div><strong>Read your first report</strong><p>'+(analysed?'Saved balances, prices and coverage are ready.':'Adding a wallet starts one explicit research job. Follow its progress in Activity; gaps are shown in the report.')+'</p></div><a class="quiet-button" href="#/activity">View Activity</a></li>'+
22
22
  '<li><span class="setup-check">'+(ai?'✓':'4')+'</span><div><strong>Connect your AI <small>optional</small></strong><p>'+(ai?'Connected. Wallet context still follows your explicit permissions.':'Use your installed Codex or Claude account when you are ready. Tracking works without it.')+'</p></div><button class="quiet-button" id="setup-ai" type="button">'+(ai?'Permissions':'Connect AI')+'</button></li></ol></details>';
23
23
  $('setup-wallet').addEventListener('click',openAdd);
24
- $('setup-discovery').addEventListener('click',()=>$('open-settings').click());
24
+ $('setup-discovery').addEventListener('click',()=>openDataConnections());
25
25
  $('setup-ai').addEventListener('click',openAgent);
26
26
  }
27
27
  async function loadSetupReadiness(){
@@ -128,7 +128,7 @@ function renderWatching() {
128
128
  const discovery=typeof setupReadiness!=='undefined'?setupReadiness:null,ready=discovery?.discovery_key_available===true;
129
129
  $('wallet-discovery-warning').hidden=ready||!!submission||state?.demo===true;
130
130
  $('wallet-discovery-warning-title').textContent=!discovery?'Token discovery is not verified':discovery.discovery_configured?'Token discovery needs a connection':'Token discovery is off';
131
- $('wallet-discovery-warning-text').textContent=discovery?.discovery_configured?'The selected indexer credential is unavailable. Connect it before researching, or continue with native and Mint Club assets only.':'Other ERC20 holdings may be missing. Connect an indexer before researching, or continue with native and Mint Club assets only.';
131
+ $('wallet-discovery-warning-text').textContent=discovery?.discovery_configured?'The selected Alchemy key is unavailable. Connect your data provider before researching, or continue with native and Mint Club assets only.':'Other ERC20 holdings may be missing. Connect a data provider before researching, or continue with native and Mint Club assets only.';
132
132
  $('wallet-register').textContent = watchingSubmitting ? 'Saving wallet…' : ready||state?.demo?'Add and research':'Add with limited discovery';
133
133
  if (browser && $('wallet-dialog').open && watchingLastStatus === 'requesting' && current.status === 'accounts')
134
134
  $('wallet-accounts').querySelector('button')?.focus();
@@ -31,9 +31,9 @@
31
31
  const connection=missing&&(disabled||noKey);
32
32
  return {title:'Some balances could not be checked',
33
33
  message:'On-chain checks did not finish on '+rpc.length+(rpc.length===1?' network.':' networks.')+' Missing balances are unknown, so this portfolio is partial. Wait a moment, then retry holdings. If errors continue, review RPC settings or use a different provider.'+(connection?' Token discovery also needs a connection to find other ERC20 holdings.':''),
34
- action:'refresh',settings:true,rpcIssues:true,refreshLabel:'Retry holdings',settingsLabel:connection?'Review connections':'Review RPC settings'};
34
+ action:'refresh',settings:true,rpcIssues:true,refreshLabel:'Retry holdings',settingsLabel:connection?'Review data connection':'Review RPC settings'};
35
35
  }
36
- if(missing&&disabled)return {title:'Token discovery is off',message:'This analysis checks native balances and Mint Club assets only. Other ERC20 holdings may be missing. Connect token discovery, then refresh holdings.',action:'settings'};
36
+ if(missing&&disabled)return {title:'Token discovery is off',message:'This analysis checks native balances and Mint Club assets only. Other ERC20 holdings may be missing. Connect a data provider, then refresh holdings.',action:'settings'};
37
37
  if(missing&&noKey)return {title:'Token discovery needs a connection',message:'The selected indexer credential is unavailable. Review its connection, then refresh holdings. This portfolio is partial.',action:'settings'};
38
38
  if(missing&&readiness?.discovery_key_available&&chains.some(c=>['disabled','missing_credential'].includes(c.discovery_status)))return {title:'Refresh holdings to discover other tokens',message:'Your discovery connection is now available. This saved analysis predates it and remains partial until you refresh holdings.',action:'refresh'};
39
39
  return {title:'Your portfolio is partial',message:'Some token discovery or on-chain checks did not complete. Missing holdings remain unknown. Review the network details below before refreshing holdings.',action:readiness?.discovery_key_available?'refresh':'settings'};
@@ -287,9 +287,9 @@ main { margin-left: 232px; width: calc(100% - 232px); }
287
287
  .watching-dialog { width: min(520px, calc(100% - 32px)); }
288
288
  .watching-dialog [hidden], #wallet-session-panel[hidden] { display: none; }
289
289
  .watching-dialog .text-button { min-height: 44px; padding: 10px 2px; margin-right: 12px; }
290
- .wallet-entry-modes { display: grid; grid-template-columns: 1fr 1fr; gap: 4px; background: #eee9f3; padding: 4px; border-radius: 12px; margin: 22px 0; }
291
- .wallet-entry-modes button { min-height: 44px; padding: 9px 8px; border: 0; background: transparent; color: var(--secondary); border-radius: 9px; font: inherit; font-weight: 500; cursor: pointer; }
292
- .wallet-entry-modes button[aria-pressed=true] { color: var(--ink); background: #fff; box-shadow: 0 1px 4px #30213e12; }
290
+ .wallet-entry-modes { display: flex; flex-wrap: wrap; gap: 9px; margin: 22px 0; }
291
+ .wallet-entry-modes button { flex: 1 1 130px; min-height: 44px; }
292
+ .wallet-entry-modes button[aria-pressed=true] { color: #fff; background: var(--accent, #6b5189); border-color: var(--accent, #6b5189); }
293
293
  .watching-dialog.is-review .wallet-entry-modes { margin: 16px 0; }
294
294
  .watching-dialog.is-review .wallet-preview { padding: 16px; }
295
295
  .watching-dialog h3 { font-size: 14px; font-weight: 600; letter-spacing: -.01em; margin: 0 0 8px; }
@@ -468,3 +468,11 @@ main { margin-left: 232px; width: calc(100% - 232px); }
468
468
  .scene-caption small { max-width: 112px; }
469
469
  }
470
470
  @media (prefers-reduced-motion: reduce) { .spinner { animation: none; } }
471
+
472
+ .settings-section + .settings-section { margin-top:24px; padding-top:20px; border-top:1px solid #e2dce8; }
473
+ .connection-guide { padding:14px; margin:14px 0; border:1px solid #d8cfdf; border-radius:12px; font-size:13px; line-height:1.6; }
474
+ .connection-guide p { margin-bottom:0; }
475
+ .connection-guide code { overflow-wrap:anywhere; }
476
+ #connection-advanced { margin-top:22px; }
477
+ #connection-advanced > label { margin-top:14px; }
478
+ #settings-done { margin-top:8px; }