kira-wallet 0.1.6 → 0.1.7

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/README.md CHANGED
@@ -43,12 +43,11 @@ For a viewer without wallet or research actions, use `kira start --read-only`.
43
43
  Prefer an archive? [GitHub releases](https://github.com/realproject7/kira-wallet/releases/tag/v0.1.2)
44
44
  include the same package and its checksum.
45
45
 
46
- 1. **Set up discovery.** In **Workspace settings**, connect an indexer for broad
47
- ERC20 discovery. Public RPC alone has limited discovery; incomplete coverage
46
+ 1. **Review optional coverage.** Free research is enabled by default. In **Workspace settings**, connect Alchemy for broader
47
+ ERC20 discovery. Free token sources have limited coverage; incomplete coverage
48
48
  stays visible. An AI or browser wallet connection does not enable an indexer.
49
49
  2. **Add a watching wallet.** Paste a public address and give it a name. Add more
50
- wallets whenever you need them. Without discovery, the form explicitly
51
- offers limited research of native and Mint Club assets.
50
+ wallets whenever you need them. The form starts free research of native, common tokens and Mint Club assets, with keyless discovery on supported networks. First balances appear before detailed research finishes.
52
51
  3. **Connect your AI.** Install and sign in to the
53
52
  [Codex CLI](https://developers.openai.com/codex/cli) or
54
53
  [Claude Code](https://code.claude.com/docs/en/quickstart), then choose your
@@ -28,3 +28,9 @@ identifies the OWS wallet-creation integration without implying endorsement.
28
28
  Its retrieval date and source digest are in `site/assets/ows-artwork-source.json`.
29
29
 
30
30
  CASHCAT, BLAST and USDB artwork in the public reconstructed demo comes from lpTOKEN.fun token metadata, DEX Screener and CoinGecko. Token marks belong to their respective owners and identify the assets without implying affiliation or endorsement. The source URLs and chain-specific contract identities are recorded in `site/assets/token-artwork-sources.json`.
31
+
32
+ The factual token inventory in `sources/known-tokens.json` includes selected
33
+ chain/contract identities from Uniswap default-token-list at commit
34
+ 583cb696cc15778536fe0ddd416de5bd81be2023. The upstream references retain
35
+ GPL-3.0 terms in `sources/TOKEN_LIST_LICENSE.txt`. Per-contract provenance is
36
+ recorded in the catalog. No upstream executable code or artwork is included.
@@ -60,3 +60,16 @@ through the user's CLI; local storage does not imply an offline model.
60
60
 
61
61
  See [the conversation contract](ONBOARDING_CHAT_CONTRACT.md).
62
62
  The standalone stdio MCP adapter above remains read-only and separate.
63
+
64
+ Native responses select the final Codex agent message. Older verified event
65
+ streams without a phase use their last agent message. Commentary cannot become
66
+ a tool request by concatenation. A mixed or malformed request gets one bounded
67
+ correction; no tool executes from it and repeated malformed protocol fails
68
+ without adding raw JSON to conversation history. Turn receipts expose only
69
+ bounded tool names and completed/failed outcomes.
70
+
71
+ Job reads include analysis phase, parent/continuation IDs and real progress
72
+ events. Normalized chain discovery completeness is separate from a job's
73
+ on-chain checkpoint status. Deferred candidates and pending networks are not
74
+ failed reads or zero holdings. Historical observations cannot establish current
75
+ execution, transfers or realized profit.
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.5. Version 0.1.6 is a preparation candidate. GitHub v0.1.3 remains
13
+ The operator published npm version 0.1.6. Version 0.1.7 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.
@@ -46,7 +46,7 @@ Published 0.1.4 explains limited discovery before wallet registration and beside
46
46
  holdings. Missing coverage keeps the portfolio estimate partial. Existing snapshots require a holdings refresh after connecting
47
47
  an indexer; a price refresh does not discover missing tokens.
48
48
 
49
- Public RPC checks known assets and Mint Club. For broader ERC20 discovery,
49
+ Free research checks known assets, Mint Club and keyless token candidates on supported networks. Alchemy is optional. For broader ERC20 discovery,
50
50
  open Custom RPC & token coverage, select Alchemy and follow its local key guide.
51
51
  Published 0.1.5 uses one save for Alchemy RPC and discovery; chain overrides
52
52
  remain under Advanced. Never paste the key into the browser. `kira doctor`
package/docs/FREE_RPC.md CHANGED
@@ -52,15 +52,42 @@ Registry identity caching remains separate from balances.
52
52
 
53
53
  Native balances and the selected issuer/deployment-backed contracts in
54
54
  `sources/known-tokens.json` are checked before cold Mint Club enumeration.
55
- The small list includes USDC on seven supported mainnets, USDT on Ethereum and
56
- Avalanche, and WETH on Arbitrum. Decimals and quantities come from on-chain
57
- reads. This list does not cover all common tokens or all wrapped/bridged variants.
58
-
59
- The full Mint Club registry is still scanned. Public RPC cannot enumerate all
60
- arbitrary ERC20 contracts held by an address. Discovery remains incomplete
61
- without a working indexer on that network. Incomplete holdings show a persistent
62
- notice on Home and the wallet page, with a path to
63
- [Alchemy setup](ALCHEMY_SETUP.md) and an explicit holdings refresh.
55
+ The catalog has 57 chain/contract identities on eight mainnets. Issuer references
56
+ and selected Uniswap token-list records retain per-contract provenance. The
57
+ upstream list commit and its license are bundled; no untrusted runtime list is
58
+ loaded. Decimals and quantities come from on-chain reads. This remains a bounded
59
+ inventory, not a claim to discover every wrapped or arbitrary token.
60
+
61
+ Default research also uses Routescan without a key on individually verified
62
+ Ethereum, Avalanche and Blast routes. Its documented free access is limited to
63
+ two requests per second and 10,000 calls per day. Kira starts at most one call
64
+ per second, reads at most 20 pages of 100 candidates per chain and spends at most
65
+ 25 seconds per enumeration. An isolated HTTP reader enforces a total deadline,
66
+ including DNS and response bodies. Unsafe pagination, outages, malformed data
67
+ and limits keep coverage partial. Provider quantities and prices never replace
68
+ RPC-verified balances. Other Routescan chains are not presumed supported.
69
+ Explicit keyless opt-outs and custom-only preferences remain respected.
70
+
71
+ Initial reports check Base, Ethereum and Blast, up to 128 candidates per chain.
72
+ Selected common contracts take priority. Remaining candidates and networks are
73
+ explicitly pending. A durable background continuation scans every configured
74
+ network, the full Mint Club registry and markets. Registry identity prefixes
75
+ are saved incrementally; balances are never shared between wallets. Batches
76
+ contain at most 128 reads, with splitting only for verified payload limits.
77
+ New initial work takes priority over detailed research. Older same-wallet
78
+ continuations cannot replace newer evidence.
79
+
80
+ Public RPC cannot enumerate all arbitrary ERC20 contracts. Alchemy is an
81
+ optional broader candidate source and backup RPC. Discovery calls made through
82
+ the wallet engine have a 25-second initial or 60-second detailed per-chain
83
+ budget. Native balances, free candidates and Mint Club research remain usable
84
+ without it. Missing or deferred evidence stays unknown. Persistent coverage
85
+ notices distinguish pending work from failed reads and offer retry or optional
86
+ [Alchemy setup](ALCHEMY_SETUP.md).
87
+
88
+ Keyless sources: [Routescan addresses](https://routescan.io/docs/api/addresses),
89
+ [API access](https://routescan.io/docs/plans-and-limits/api-keys-and-pricing),
90
+ [rate limits](https://routescan.io/docs/plans-and-limits/rate-limits).
64
91
 
65
92
  ## Read preferences
66
93
 
@@ -121,3 +121,27 @@ its projection receipt without another provider request.
121
121
 
122
122
  The read-only stdio MCP adapter uses the same job and snapshot service. It has
123
123
  no mutation tools and requires separate operator-approved client setup.
124
+
125
+ ## Initial evidence and continuations in 0.1.7
126
+
127
+ A valid wallet admission records its identity under a short registry lock.
128
+ Idempotent replay repairs missing admission without undoing later name edits.
129
+ New analysis jobs have `analysis_phase: baseline`. The first usable chain
130
+ publishes an immutable `initial/` snapshot; the running job can reference it
131
+ without claiming that the job is finished. The baseline publishes its own final
132
+ partial snapshot and admits exactly one durable `enrichment` continuation with
133
+ `parent_job_id`. The parent records `continuation_id`.
134
+
135
+ Baseline work runs before enrichment. New baseline admission can preempt a
136
+ running continuation. Saved chain evidence and verified registry identity
137
+ prefixes survive; an interrupted registry batch is read again. A newer request
138
+ for the same wallet supersedes older detailed work. Generation checks prevent
139
+ an older result from replacing the latest pointer, while preserving historical
140
+ evidence. Explicit cancellation of baseline prevents automatic continuation.
141
+
142
+ `onchain_progress` events identify the actual operation, chain, checked count
143
+ and known total. `chain.status: complete` concerns the on-chain checkpoint.
144
+ Normalized wallet `complete` concerns general token discovery. Registry coverage,
145
+ candidate failures, deferred candidates and pending RPC reads are separate facts.
146
+ These flags do not prove exhaustive holdings. First results can be read while
147
+ background work remains active.
package/docs/RELEASE.md CHANGED
@@ -49,15 +49,14 @@ Claude model choices, exposes model and permission changes in Workspace settings
49
49
  and provides one Alchemy connection save. Its registry version was confirmed on
50
50
  2026-10-09. Published package bytes remain immutable.
51
51
 
52
- The next candidate is **0.1.6**. It adds a paced local RPC pool, core-chain public
52
+ Published **0.1.6** adds a paced local RPC pool, core-chain public
53
53
  alternatives, public-first custom backup, fixed-block hash checks and a small
54
54
  known-token catalog. Home and wallet pages display incomplete coverage with an
55
55
  Alchemy setup action. The settings guide explains account setup, private local
56
56
  key import and an explicit holdings refresh. Missing or malformed discovery data
57
57
  cannot become a completed empty inventory.
58
58
 
59
- Version 0.1.6 requires review and operator registry publication. It is not yet
60
- published. The showcase remains a separate deployment. See
59
+ Version 0.1.6 is published and immutable. The showcase remains a separate deployment. See
61
60
  [free RPC behavior](FREE_RPC.md) and [Alchemy setup](ALCHEMY_SETUP.md).
62
61
 
63
62
  The operator published npm **0.1.2** on 2026-10-05. Its registry archive was
@@ -80,7 +79,7 @@ The earlier unpublished 0.1.2 candidate is preserved in the ignored
80
79
  `dist/preserved-0.1.2-bad8f02/` directory. Use the current `dist/release.json`
81
80
  and its matching archive for publication; do not mix manifests from candidates.
82
81
 
83
- Keep GitHub v0.1.1, its tag and its released archive immutable. Versions 0.1.2, 0.1.3, 0.1.4 and 0.1.5 are also published and immutable. Do not rebuild them from later documentation
82
+ Keep GitHub v0.1.1, its tag and its released archive immutable. Versions 0.1.2, 0.1.3, 0.1.4, 0.1.5 and 0.1.6 are also published and immutable. Do not rebuild them from later documentation
84
83
  commits. Future package changes need a new version. Release preparation does not
85
84
  publish a GitHub release, merge PR 19, promote the website or publish to npm.
86
85
  Registry publication remains operator-only. The coding agent never publishes.
@@ -95,18 +94,38 @@ python3 scripts/prepare-release.py
95
94
  ```
96
95
 
97
96
  `prepare-release.py` scans the package boundary and creates
98
- `dist/kira-wallet-0.1.6.tgz`. The ignored `dist/release.json` records its source
97
+ `dist/kira-wallet-0.1.7.tgz`. The ignored `dist/release.json` records its source
99
98
  commit, SHA-256 and member count. It never publishes. After reviewing that
100
99
  manifest, the operator's final command from the project root is:
101
100
 
102
101
  ```sh
103
- npm publish ./dist/kira-wallet-0.1.6.tgz --access public
102
+ npm publish ./dist/kira-wallet-0.1.7.tgz --access public
104
103
  ```
105
104
 
106
105
  Use the operator's npm account and complete authentication or OTP personally.
107
106
  After publication, verify the registry archive and perform a fresh install
108
107
  before marking the new onboarding flow as available to npm users.
109
108
 
109
+ ## Proposed 0.1.7 behavior
110
+
111
+ The candidate enables useful keyless research by default, expands the curated
112
+ contract inventory and separates first recorded balances from exhaustive
113
+ background research. Pending candidates, registry work and other networks stay
114
+ visible. Current wallet evidence cannot roll back when an older continuation
115
+ resumes. See [coverage and limits](FREE_RPC.md).
116
+
117
+ Codex uses the final native message rather than joined commentary. Mixed tool
118
+ protocol receives one bounded correction and is never rendered as a completed
119
+ answer. Claude retains its restricted native adapter. Tool receipts contain
120
+ names and outcomes, without raw arguments. Job checkpoints and normalized token
121
+ discovery flags have separate meanings. Imported connection references reconcile
122
+ with untouched settings fields while preserving edited drafts.
123
+
124
+ The next archive is **0.1.7**, unpublished until the operator runs the command.
125
+ Release readiness requires the repository suite, clean installation, source and
126
+ history privacy scans, independent review and the frozen archive manifest.
127
+ No live-extension or human passphrase acceptance is inferred from these checks.
128
+
110
129
  ## Verified 0.1.0 publication
111
130
 
112
131
  The operator published 0.1.0, verified on 2026-10-04. The registry archive has
package/kira_agent.py CHANGED
@@ -42,7 +42,7 @@ SYSTEM = ('You are Kira, a careful wallet research partner. Answer in the langua
42
42
  'and full-balance output only when a validated quote exists. DEX pool TVL is not a sell quote. '
43
43
  'Missing execution quotes mean sale proceeds, fees, gas and price impact are unknown. '
44
44
  'For network questions, group by wallet and distinguish known assets from missing coverage. '
45
- 'A chain complete flag means general ERC20 discovery was checked; it does not prove native RPC reads succeeded. An indexer check and unavailable RPC are separate coverage facts, not conflicting records. '
45
+ 'In normalized wallet facts, chain complete means general ERC20 discovery was checked. In job chains, status complete means an on-chain checkpoint finished. These are different stages. Neither flag alone proves all holdings are known. registry_complete and candidate_balance_errors describe separate read coverage. An indexer check and unavailable RPC are separate coverage facts, not conflicting records. '
46
46
  'An explicit chains.native_balance of zero is an observed native balance at its recorded time and block; null is unknown. Empty positive-holding inventories do not erase these observations. '
47
47
  'For saved-analysis comparisons, use snapshots_compare and read both snapshots. coverage_changed compares full evidence, including times and blocks, not just network status flags. price_references_changed may include changed reference values or times, not balances. '
48
48
  'Snapshot reads are normalized evidence. Compare chain observations and snapshot_price_references; never claim these are the only raw fields that changed. '
@@ -140,7 +140,7 @@ def wallet_facts(wallets, *, pool_budget=40_000):
140
140
  for wallet in wallets:
141
141
  row = pick(wallet,('address','tags','name','analysed_at','prices_at','balance_observed_at','known_value_usd','unpriced_count','status'))
142
142
  if not wallet.get('analysed_at'): row['known_value_usd']=None
143
- row['chains'] = [pick(c,('id','name','environment','complete','rpc_available','native_symbol','native_balance','native_observed_at','native_block_number','registry_block_number')) for c in wallet['chains']]
143
+ row['chains'] = [pick(c,('id','name','environment','complete','rpc_available','native_symbol','native_balance','native_observed_at','native_block_number','registry_block_number','discovery_status','registry_complete','registry_phase','rpc_pending','candidate_balance_errors','candidate_deferred')) for c in wallet['chains']]
144
144
  row['snapshot_price_references'] = wallet.get('snapshot_price_references',{})
145
145
  row['assets'] = []
146
146
  for asset in wallet['assets']:
@@ -211,11 +211,14 @@ def run_native(config, prompt, cancel, *, timeout=180):
211
211
  else:
212
212
  events = [json.loads(line) for line in output.splitlines() if line.strip()]
213
213
  if not any(e.get('type') == 'turn.completed' for e in events): raise ValueError()
214
- answer = '\n\n'.join(e['item'].get('text','') for e in events if e.get('type') == 'item.completed' and e.get('item',{}).get('type') == 'agent_message')
214
+ messages = [e['item'] for e in events if e.get('type') == 'item.completed' and e.get('item',{}).get('type') == 'agent_message']
215
+ finals = [m for m in messages if m.get('phase')=='final_answer']
216
+ # Older verified CLIs omit phase. Only their last message is final.
217
+ answer = (finals or [m for m in messages if not m.get('phase')])[-1].get('text','') if finals or any(not m.get('phase') for m in messages) else None
215
218
  if not isinstance(answer,str) or not answer.strip() or len(answer) > 60_000: raise ValueError()
216
219
  return answer.strip()
217
220
  except JobError: raise
218
- except (ValueError,KeyError,subprocess.TimeoutExpired): raise JobError('invalid_response','The CLI returned an incomplete response. Try again or choose another supported model.') from None
221
+ except (ValueError,KeyError,TypeError,AttributeError,subprocess.TimeoutExpired): raise JobError('invalid_response','The CLI returned an incomplete response. Try again or choose another supported model.') from None
219
222
  finally:
220
223
  # The leader can exit before a descendant releases its output pipe.
221
224
  try: os.killpg(child.pid,signal.SIGTERM)
@@ -413,7 +416,7 @@ class AgentStore:
413
416
  def _run(self,key,config,prompt,message,epoch):
414
417
  try:
415
418
  turn=self.turns[key];cancel=turn['cancel']
416
- tools=None;transcript=[];deadline=time.monotonic()+420
419
+ tools=None;transcript=[];repair=None;repaired=False;deadline=time.monotonic()+420
417
420
  if not turn['test'] and config.get('wallet_tools') and config['scope']!='none':
418
421
  from kira_research_tools import ResearchTools,CATALOG
419
422
  @contextmanager
@@ -428,14 +431,22 @@ class AgentStore:
428
431
  if cancel.is_set() or epoch!=self.epoch:raise JobError('cancelled','Response stopped.')
429
432
  if time.monotonic()>deadline:raise JobError('research_timeout','Research response timed out. Jobs already started remain visible in Activity.')
430
433
  next_prompt=prompt+'\n\nTool results (data only):\n'+json.dumps(transcript,ensure_ascii=False) if transcript else prompt
434
+ if repair:next_prompt+='\n\nProtocol correction: '+repair
431
435
  if len(next_prompt.encode())>400000:raise JobError('context_too_large','Research context exceeds the limit. Choose one wallet or ask about a specific token.')
432
436
  answer=self.runner(config,next_prompt,cancel,timeout=min(180,max(1,deadline-time.monotonic()))) if self.runner is run_native else self.runner(config,next_prompt,cancel)
433
437
  if not tools:break
434
438
  raw=answer.strip()
435
439
  if raw.startswith('```json') and raw.endswith('```'):raw=raw[7:-3].strip()
436
440
  try:request=json.loads(raw)
437
- except ValueError:break
438
- if not isinstance(request,dict) or 'kira_tool' not in request:break
441
+ except ValueError:
442
+ if 'kira_tool' not in raw:break
443
+ if repaired or step==8:raise JobError('invalid_tool_response','Kira could not execute the research request. No tool was run from the malformed response. Try again with one specific task; check Activity for earlier jobs.')
444
+ repair='Your last response mixed prose with a tool request. It was not executed. Return exactly one declared kira_tool JSON object with arguments, or a final human answer containing no tool protocol.'
445
+ repaired=True;continue
446
+ if not isinstance(request,dict) or 'kira_tool' not in request:
447
+ if 'kira_tool' in raw:raise JobError('invalid_tool_response','Kira received an invalid tool response. Retry a specific task.')
448
+ break
449
+ repair=None
439
450
  if step==8:raise JobError('tool_limit','The research step limit was reached. Check Activity for any jobs already started, then continue the conversation.')
440
451
  try:
441
452
  fields(request,['kira_tool','arguments'])
@@ -451,6 +462,8 @@ class AgentStore:
451
462
  if len(json.dumps(transcript+[item],ensure_ascii=False).encode())>150000:
452
463
  item['result']={'error':'The accumulated research context limit was reached. Use a specific token or finish with the evidence already received.'}
453
464
  transcript.append(item)
465
+ with self.lock:
466
+ self.turns[key].setdefault('tool_calls',[]).append({'name':request['kira_tool'],'status':'failed' if isinstance(result,dict) and result.get('error') else 'completed'})
454
467
  with self.lock:self.tool_status='Kira is analysing the results…'
455
468
 
456
469
  with self.lock:
@@ -474,7 +487,7 @@ class AgentStore:
474
487
  def read(self,key):
475
488
  with self.lock:
476
489
  if key not in self.turns:raise JobError('not_found','This response is no longer available. Start a new conversation.')
477
- return {**{k:self.turns[key][k] for k in ('id','state','error','answer','test','message','conversation_id')},'tool_status':self.tool_status if self.active==key else None}
490
+ return {**{k:self.turns[key][k] for k in ('id','state','error','answer','test','message','conversation_id')},'tool_calls':self.turns[key].get('tool_calls',[]),'tool_status':self.tool_status if self.active==key else None}
478
491
 
479
492
  def cancel(self,key):
480
493
  with self.lock:
package/kira_cli.py CHANGED
@@ -235,7 +235,7 @@ def main():
235
235
  from kira_config import provider_config
236
236
  cfg=provider_config(cfg,'alchemy',args.key_env)
237
237
  cfg['secret_env_file']=str(file);cfg['discovery']['explorers']=True
238
- elif args.command=='discovery':cfg['discovery']={'provider':args.provider,'key_env':args.key_env,'explorers':args.explorers}
238
+ elif args.command=='discovery':cfg['discovery']={**cfg['discovery'],'provider':args.provider,'key_env':args.key_env,'explorers':args.explorers}
239
239
  elif args.setting=='public':cfg['rpc']['mode']='public'
240
240
  else:
241
241
  cfg['rpc'].update(mode='custom')
package/kira_config.py CHANGED
@@ -17,7 +17,7 @@ def config_path():
17
17
 
18
18
  def default_config():
19
19
  return {'schema_version':1,'rpc':{'mode':'public','allow_public_fallback':True,'priority':'public_first','chains':{}},
20
- 'discovery':{'provider':'none','key_env':'ALCHEMY_API_KEY','explorers':False}}
20
+ 'discovery':{'provider':'none','key_env':'ALCHEMY_API_KEY','explorers':False,'public':True}}
21
21
 
22
22
  def provider_config(config, provider, key_env):
23
23
  """One settings transition. Preserve explicit fallback and custom overrides."""
@@ -45,7 +45,8 @@ def load_config():
45
45
  if not isinstance(discovery,dict) or discovery.get('provider') not in ('none','alchemy') or type(discovery.get('explorers',False)) is not bool:
46
46
  raise ValueError('Invalid discovery configuration.')
47
47
  if rpc.get('priority','public_first') not in ('public_first','custom_first'):raise ValueError('Invalid RPC priority.')
48
- if set(rpc)-{'mode','allow_public_fallback','priority','chains'} or set(discovery)-{'provider','key_env','explorers'}:raise ValueError('Unknown provider configuration fields.')
48
+ if type(discovery.get('public',True)) is not bool:raise ValueError('Invalid public discovery preference.')
49
+ if set(rpc)-{'mode','allow_public_fallback','priority','chains'} or set(discovery)-{'provider','key_env','explorers','public'}:raise ValueError('Unknown provider configuration fields.')
49
50
  if any(not isinstance(row,dict) or set(row)-{'url_env','alchemy_network'} for row in rpc['chains'].values()):raise ValueError('Unknown RPC endpoint fields.')
50
51
  for name in [discovery.get('key_env'),*[row.get('url_env') if isinstance(row,dict) else None for row in rpc['chains'].values()]]:
51
52
  if not isinstance(name,str) or not re.fullmatch(r'[A-Za-z_][A-Za-z0-9_]*',name):raise ValueError('Credentials must use valid environment references.')
package/kira_jobs.py CHANGED
@@ -123,13 +123,16 @@ class JobStore:
123
123
  job = read(path)
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
- optional={'raw_hash','control_receipts','control_raw','provider_configuration','started_at'}
126
+ optional={'raw_hash','control_receipts','control_raw','provider_configuration','started_at','analysis_phase','parent_job_id','continuation_id'}
127
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
130
  'settings.rpc':['mode','chains','allow_public_fallback'],'settings.discovery':['provider','key_env'],'settings.provider':['provider','key_env']}[job['operation']]
131
131
  if job['operation']=='settings.rpc' and 'priority' in job['input']:names=names+['priority']
132
132
  fields(job['input'],names)
133
+ if 'analysis_phase' in job and job['analysis_phase'] not in ('baseline','enrichment'):raise JobError('invalid_envelope','Invalid analysis phase.')
134
+ for field in ('parent_job_id','continuation_id'):
135
+ if field in job:identifier(job[field])
133
136
  wallet=job['input'].get('wallet') or job['input'].get('address')
134
137
  if wallet is not None and (not isinstance(wallet,str) or not ADDRESS.fullmatch(wallet)):raise JobError('invalid_envelope','Persisted wallet identity is invalid.')
135
138
  return job
@@ -206,7 +209,27 @@ class JobStore:
206
209
  return value.copy()
207
210
  raise JobError('unsupported_operation', 'Unsupported job operation.')
208
211
 
209
- def submit(self, request):
212
+ def admit_wallet(self, job):
213
+ if job['operation'] not in ('wallet.add','wallet.refresh'):return job
214
+ from wallet import register, registry_locked
215
+ with registry_locked(self.root):
216
+ registry=read(self.root/'wallets.json')
217
+ admissions=registry.setdefault('wallet_admissions',{})
218
+ address=job['input'].get('address') or job['input'].get('wallet')
219
+ entry=next((w for w in registry['wallets'] if w['address_key']==address.lower()),None)
220
+ if job['job_id'] not in admissions:
221
+ legacy_done=not job.get('analysis_phase') and (job['state']!='queued' or job['attempt']>0) and entry is not None
222
+ if job['operation']=='wallet.add' and not legacy_done:
223
+ entry=register(registry,address,job['input']['tag'])
224
+ if job.get('analysis_phase')=='baseline':entry['research_generation']=job['job_id']
225
+ admissions[job['job_id']]=address.lower()
226
+ atomic(self.root/'wallets.json',registry)
227
+ elif entry is None and job['operation']=='wallet.add':
228
+ register(registry,address,job['input']['tag'])
229
+ atomic(self.root/'wallets.json',registry)
230
+ return job
231
+
232
+ def submit(self, request, *, phase='baseline', parent=None):
210
233
  fields(request, ['schema_version', 'operation', 'input', 'idempotency_key'])
211
234
  if type(request['schema_version']) is not int or request['schema_version'] != 1:
212
235
  raise JobError('unsupported_version', 'Expected request schema version 1.')
@@ -218,19 +241,19 @@ class JobStore:
218
241
  if not isinstance(operation,str):raise JobError('unsupported_operation','Expected a typed operation name.')
219
242
  key_hash = digest(key); receipt = self.jobs / 'requests' / (key_hash + '.json')
220
243
  raw_hash=digest({'operation':operation,'input':request['input']})
221
- if receipt.exists() and read(receipt).get('raw_hash')==raw_hash:return self.get(read(receipt)['job_id'])
244
+ if receipt.exists() and read(receipt).get('raw_hash')==raw_hash:return self.admit_wallet(self.get(read(receipt)['job_id']))
222
245
  for p in self.jobs.glob('*.json'):
223
246
  saved=self.get(p.stem)
224
247
  if saved.get('control_raw',{}).get(key_hash)==raw_hash or saved['key_hash']==key_hash and saved.get('raw_hash')==raw_hash:
225
248
  fingerprint=saved.get('control_receipts',{}).get(key_hash,saved['request_hash'])
226
249
  atomic(receipt,{'schema_version':1,'request_hash':fingerprint,'raw_hash':raw_hash,'job_id':saved['job_id']})
227
- return saved
250
+ return self.admit_wallet(saved)
228
251
  value = self.normalize(operation, request['input'])
229
252
  fingerprint = digest({'operation': operation, 'input': value})
230
253
  if receipt.exists():
231
254
  old = read(receipt)
232
255
  if old['request_hash'] != fingerprint: raise JobError('idempotency_conflict', 'This request key already belongs to another operation.')
233
- return self.get(old['job_id'])
256
+ return self.admit_wallet(self.get(old['job_id']))
234
257
  # Recover an envelope committed before its request receipt.
235
258
  existing = next((j for p in self.jobs.glob('*.json') if (j := self.get(p.stem))['key_hash'] == key_hash or key_hash in j.get('control_receipts',{})), None)
236
259
  if existing:
@@ -261,7 +284,10 @@ class JobStore:
261
284
  'created_at': now(), 'updated_at': now(), 'sequence': 0, 'cancel_requested': False,
262
285
  'stage': 'queued', 'events': [], 'checkpoint': 'snapshots/jobs/' + job_id,
263
286
  'result': None, 'previous_result': previous, 'errors': [],'chains':{}}
287
+ if operation in ('wallet.add','wallet.refresh'):job['analysis_phase']=phase
288
+ if parent:job['parent_job_id']=parent
264
289
  self.save(job)
290
+ self.admit_wallet(job)
265
291
  atomic(receipt, {'schema_version': 1, 'request_hash': fingerprint,'raw_hash':raw_hash, 'job_id': job['job_id']})
266
292
  return job
267
293
 
@@ -361,11 +387,11 @@ class JobStore:
361
387
  self.save(job)
362
388
 
363
389
  def event(self, job_id, event):
364
- allowed = {'registered', 'discovery', 'chain', 'dex_discovery', 'token_images', 'published', 'failed'}
390
+ allowed = {'registered', 'discovery', 'chain', 'onchain_progress', 'first_evidence', 'dex_discovery', 'token_images', 'published', 'failed'}
365
391
  if event.get('stage') not in allowed: return
366
392
  with self.locked():
367
393
  job = self.get(job_id)
368
- counts = {k: v for k, v in event.items() if k in {'complete_chains', 'candidates', 'tokens','chain_id','registry','checked','held','block_number'} and type(v) is int and v >= 0}
394
+ counts = {k: v for k, v in event.items() if k in {'complete_chains', 'candidates', 'tokens','chain_id','registry','checked','held','block_number','total'} and type(v) is int and v >= 0}
369
395
  job['stage'] = event['stage']
370
396
  if event['stage']=='chain' and type(event.get('chain_id')) is int:
371
397
  block=event.get('block_number');block=str(block) if type(block) is int and block>=0 else block
@@ -373,20 +399,41 @@ class JobStore:
373
399
  'endpoint_index':event.get('endpoint_index') if type(event.get('endpoint_index')) is int else None,
374
400
  'status':event.get('status') if event.get('status') in ('complete','partial','unavailable') else 'unknown'}
375
401
  job['events'].append({'stage': event['stage'], 'observed_at': now(), 'counts': counts})
402
+ if event['stage']=='onchain_progress' and event.get('operation') in ('balances','registry','metadata','curves','markets'):
403
+ job['events'][-1]['context']={'operation':event['operation'],'chain_id':counts.get('chain_id')}
404
+ if event['stage']=='first_evidence' and event.get('snapshot')==job['checkpoint']+'/initial':
405
+ saved=self.snapshot(event['snapshot'])
406
+ wallet=job['input'].get('wallet') or job['input'].get('address')
407
+ if saved.get('wallet_address','').lower()==wallet.lower():job['result']={'snapshot_id':event['snapshot'],'status':saved['status'],'phase':'initial'}
376
408
  if event['stage']=='chain' and type(event.get('chain_id')) is int:job['events'][-1]['context']={'chain_id':event['chain_id'],**job['chains'][str(event['chain_id'])]}
377
409
  job['events'] = job['events'][-100:]
378
410
  self.save(job)
379
411
 
380
412
  def set_tags(self, job):
381
- registry = read(self.root / 'wallets.json')
382
- wallet = next(w for w in registry['wallets'] if w['address_key'] == job['input']['wallet'])
383
- old = wallet['tags']; tags = job['input']['tags']
384
- for tag in set(old) ^ set(tags):
385
- registry.setdefault('tag_history', []).append({'address_key': wallet['address_key'], 'tag': tag,
386
- 'action': 'add' if tag in tags else 'remove', 'observed_at': now(), 'source': 'operator'})
387
- wallet['tags'] = tags; atomic(self.root / 'wallets.json', registry)
413
+ from wallet import registry_locked
414
+ with registry_locked(self.root):
415
+ registry = read(self.root / 'wallets.json')
416
+ wallet = next(w for w in registry['wallets'] if w['address_key'] == job['input']['wallet'])
417
+ old = wallet['tags']; tags = job['input']['tags']
418
+ for tag in set(old) ^ set(tags):
419
+ registry.setdefault('tag_history', []).append({'address_key': wallet['address_key'], 'tag': tag,
420
+ 'action': 'add' if tag in tags else 'remove', 'observed_at': now(), 'source': 'operator'})
421
+ wallet['tags'] = tags; atomic(self.root / 'wallets.json', registry)
388
422
  return {'wallet': wallet['address_key'], 'tags': tags, 'status': 'completed'}
389
423
 
424
+ def continue_research(self):
425
+ """A durable continuation of the admitted analysis, behind initial reports."""
426
+ for parent in self.list():
427
+ if parent.get('analysis_phase')!='baseline' or parent['state'] not in ('succeeded','partial') or parent.get('cancel_requested') or parent.get('continuation_id'):continue
428
+ result=parent.get('result')
429
+ if not result or not result.get('snapshot_id'):continue
430
+ saved=self.snapshot(result['snapshot_id'])
431
+ if saved.get('pipeline',{}).get('phase')!='baseline':continue
432
+ wallet=parent['input'].get('address') or parent['input'].get('wallet')
433
+ child=self.submit({'schema_version':1,'operation':'wallet.refresh','input':{'wallet':wallet},'idempotency_key':'enrichment:'+parent['job_id']},phase='enrichment',parent=parent['job_id'])
434
+ with self.locked():
435
+ parent=self.get(parent['job_id']);parent['continuation_id']=child['job_id'];self.save(parent)
436
+
390
437
  def engine_command(self, job):
391
438
  if job['operation'] == 'prices.refresh':
392
439
  command = [sys.executable, str(ASSETS / 'viewer/refresh_prices.py'), '--wallet', job['input']['wallet']]
@@ -394,6 +441,7 @@ class JobStore:
394
441
  add = job['operation'] == 'wallet.add'
395
442
  command = [sys.executable, str(ASSETS / 'wallet.py'), 'add' if add else 'refresh', job['input']['address'] if add else job['input']['wallet']]
396
443
  if add: command += ['--tag', job['input']['tag']]
444
+ command += ['--phase',job.get('analysis_phase','enrichment')]
397
445
  if (self.root / job['checkpoint'] / 'run.json').exists(): command += ['--resume', job['checkpoint']]
398
446
  return command
399
447
 
@@ -412,7 +460,7 @@ class JobStore:
412
460
  previous=config['rpc']['chains'].get(key,{})
413
461
  if row['url_env']==previous.get('url_env') and previous.get('alchemy_network'):row['alchemy_network']=previous['alchemy_network']
414
462
  config['rpc']=updated
415
- elif job['operation']=='settings.discovery':config['discovery']={**job['input'],'explorers':config['discovery'].get('explorers',False)}
463
+ elif job['operation']=='settings.discovery':config['discovery']={**config['discovery'],**job['input']}
416
464
  else:
417
465
  from kira_config import provider_config,secret_values
418
466
  config=provider_config(config,**job['input'])
@@ -420,12 +468,12 @@ class JobStore:
420
468
  raise JobError('connection_key_missing','Kira cannot find the local Alchemy key. Follow the connection guide, then retry. Existing settings are unchanged.')
421
469
  atomic(config_path(),config)
422
470
  result={'status':'completed','setting':job['operation']};atomic(self.jobs/'results'/(job['job_id']+'.json'),result);return result
423
- 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'}
471
+ 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', 'KIRA_RESEARCH_GENERATION':job.get('parent_job_id',job['job_id'])}
424
472
  command=self.engine_command(job)
425
473
  child = subprocess.Popen(command, env=env, cwd=ASSETS, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL,
426
474
  start_new_session=True, pass_fds=(lease.fileno(), writer.fileno()))
427
475
  selector = selectors.DefaultSelector(); selector.register(child.stdout, selectors.EVENT_READ)
428
- buffer = b''; cancelled = False; stopped = False
476
+ buffer = b''; cancelled = False; stopped = False; yielded = False; next_check = 0
429
477
  def stop_child():
430
478
  nonlocal stopped
431
479
  if stopped:return
@@ -445,6 +493,10 @@ class JobStore:
445
493
  while child.poll() is None:
446
494
  if self.get(job['job_id'])['cancel_requested']:
447
495
  cancelled = True; stop_child(); break
496
+ if job.get('analysis_phase')=='enrichment' and time.monotonic()>=next_check:
497
+ next_check=time.monotonic()+2
498
+ if any(j['state']=='queued' and j.get('analysis_phase')=='baseline' for j in self.list()):
499
+ yielded=True;stop_child();break
448
500
  for key, _ in selector.select(.2):
449
501
  chunk = os.read(key.fileobj.fileno(), 65536)
450
502
  if not chunk: selector.unregister(key.fileobj); continue
@@ -467,6 +519,7 @@ class JobStore:
467
519
  receipt = self.publication(job)
468
520
  if receipt: return receipt
469
521
  if cancelled: raise JobError('cancelled', 'Job stopped. Previous published results and saved evidence were preserved.')
522
+ if yielded:raise JobError('yielded','Detailed research yielded to a new initial report. Saved identity checkpoints are retained.')
470
523
  raise JobError('engine_failed', 'Analysis stopped before publication. Check connection settings and resume saved evidence.')
471
524
  finally:
472
525
  stop_child(); selector.close(); child.stdout.close()
@@ -478,13 +531,18 @@ class JobStore:
478
531
  with (self.root / '.analysis.lock').open('a') as writer:
479
532
  fcntl.flock(writer, fcntl.LOCK_EX)
480
533
  self.recover()
534
+ self.continue_research()
481
535
  while True:
482
536
  writer = (self.root / '.analysis.lock').open('a')
483
537
  fcntl.flock(writer, fcntl.LOCK_EX)
484
538
  with self.locked():
485
- queued = sorted((self.get(p.stem) for p in self.jobs.glob('*.json')), key=lambda j: j['created_at'])
539
+ queued = sorted((self.get(p.stem) for p in self.jobs.glob('*.json')), key=lambda j: (j.get('analysis_phase')=='enrichment',j['created_at']))
486
540
  job = next((j for j in queued if j['state'] == 'queued'), None)
487
541
  if not job:writer.close();return
542
+ if job.get('analysis_phase')=='enrichment':
543
+ entry=self.wallet(job['input']['wallet'])
544
+ if entry.get('research_generation') not in (None,job.get('parent_job_id')):
545
+ job.update(state='cancelled',stage='stopped',errors=[{'code':'superseded','message':'Newer holdings research replaced this detailed run. Earlier evidence is preserved.'}]);self.save(job);writer.close();continue
488
546
  from kira_config import load_config
489
547
  config=load_config()
490
548
  job['provider_configuration']={'rpc_mode':config['rpc']['mode'],'discovery_provider':config['discovery']['provider'],
@@ -494,9 +552,13 @@ class JobStore:
494
552
  result = self.execute(job, lease, writer)
495
553
  with self.locked():
496
554
  job = self.get(job['job_id']); job.update(result=result, state='succeeded' if result['status'] == 'completed' else 'partial', stage='published'); self.save(job)
555
+ self.continue_research()
497
556
  except JobError as error:
498
557
  with self.locked():
499
- job = self.get(job['job_id']); job.update(state='cancelled' if error.code == 'cancelled' else 'failed', stage='stopped', errors=[{'code': error.code, 'message': str(error)}]); self.save(job)
558
+ job = self.get(job['job_id'])
559
+ if error.code=='yielded':job.update(state='queued',stage='queued',errors=[])
560
+ else:job.update(state='cancelled' if error.code == 'cancelled' else 'failed', stage='stopped', errors=[{'code': error.code, 'message': str(error)}])
561
+ self.save(job)
500
562
  except (Exception, KeyboardInterrupt):
501
563
  with self.locked():
502
564
  job = self.get(job['job_id'])
@@ -51,7 +51,7 @@ class ResearchTools:
51
51
  raise JobError('job_scope','Only approved wallet research jobs are available.')
52
52
  selector=job['input'].get('wallet') or job['input'].get('address')
53
53
  wallet=self.wallet(selector,approved)
54
- return {'wallet':wallet,**{key:job.get(key) for key in ('job_id','operation','state','stage','created_at','updated_at','checkpoint','result','chains')}}
54
+ return {'wallet':wallet,**{key:job.get(key) for key in ('job_id','operation','state','stage','created_at','updated_at','checkpoint','result','chains','analysis_phase','parent_job_id','continuation_id','events')}}
55
55
 
56
56
  def call(self, name, arguments, key):
57
57
  tool=next((row for row in CATALOG if row['name']==name),None)