kira-wallet 0.2.1 → 0.2.3

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
@@ -15,7 +15,7 @@ web app to manage wallets, inspect token markets and follow research. Your
15
15
  existing AI agent can use the same records and actions through the Kira CLI or
16
16
  optional MCP connection, then open the web app beside your conversation.
17
17
 
18
- **This branch prepares 0.2.1.** Kira now supplies wallet tools and a full-window
18
+ **Kira 0.2.2 is published on npm; this branch prepares 0.2.3.** Kira supplies wallet tools and a full-window
19
19
  management app. Your agent host manages its own conversations, models, files and
20
20
  approvals. Kira does not launch a model CLI or require a model login.
21
21
 
@@ -104,7 +104,9 @@ saved holdings; it does not discover additional tokens. Price refresh preserves
104
104
  recorded balances and does not recover missing holdings.
105
105
 
106
106
  DEX price references require at least $1,000 of reported pool liquidity and
107
- exclude extremely one-sided pools. A displayed value is an estimate, not a
107
+ exclude extremely one-sided pools. Research and price refresh both fall back to
108
+ Alchemy index prices when no DEX price or explicit liquidity rejection exists.
109
+ An index price is a reference, not proof of a liquid market. A displayed value is an estimate, not a
108
110
  promise of sale proceeds. Observation timestamps remain attached to the facts.
109
111
 
110
112
  ## Upgrading from 0.1.20
package/cashout.py CHANGED
@@ -3,6 +3,7 @@ from decimal import Decimal, InvalidOperation, localcontext
3
3
  import json
4
4
  from pathlib import Path
5
5
  import re
6
+ from market_records import checked_price
6
7
 
7
8
  CRITERIA = {'version': 2, 'minimum_quoted_usd': '1', 'mintclub_redemption_included': True,
8
9
  'gas_included': False, 'execution_verified': False,
@@ -36,6 +37,7 @@ def output_price(chain_id, address, market):
36
37
  rows = [p for p in market.get('tokens', []) if p.get('chain_id') == chain_id
37
38
  and isinstance(p.get('address'), str) and p['address'].lower() == address.lower()]
38
39
  for row in rows:
40
+ row = checked_price(row)
39
41
  price = decimal(row.get('usd'))
40
42
  if (price is not None and price > 0 and row.get('quality') == 'estimated'
41
43
  and row.get('basis') in ('DEX market', 'Market index')
package/curve_pricing.py CHANGED
@@ -3,11 +3,41 @@ from concurrent.futures import ThreadPoolExecutor
3
3
  import json
4
4
  import math
5
5
  from pathlib import Path
6
- import subprocess
7
6
  import sys
8
7
  import research as h
9
8
 
10
9
 
10
+ def index_prices(identities, folder, deadline=None):
11
+ """Read bounded Alchemy batches by exact chain/contract, retaining source time."""
12
+ from wallet import ALCHEMY, atomic, fetch
13
+ sys.path.insert(0,str(h.ASSETS/'viewer'))
14
+ from model import dex_price
15
+ import hashlib
16
+ import re
17
+ wanted=sorted({(cid,address.lower()) for cid,address in identities
18
+ if cid in ALCHEMY and isinstance(address,str) and re.fullmatch(r'0x[0-9a-fA-F]{40}',address)})
19
+ key=h.secrets().get('ALCHEMY_CUSTOM_APY_KEY') if wanted else None
20
+ prices={}
21
+ for start in range(0,len(wanted) if key else 0,25):
22
+ batch=wanted[start:start+25]
23
+ requests=[{'network':ALCHEMY[cid],'address':address} for cid,address in batch]
24
+ file=Path(folder)/('reserve-price-cache-'+hashlib.sha256(json.dumps(requests,sort_keys=True).encode()).hexdigest()+'.json')
25
+ record=json.loads(file.read_text()) if file.exists() else None
26
+ if record is None:
27
+ response=fetch('https://api.g.alchemy.com/prices/v1/'+key+'/tokens/by-address',{'addresses':requests},deadline=deadline)
28
+ record={'observed_at':h.now(),'source':'Alchemy token prices by address','request':requests,'response':response}
29
+ if isinstance(response,dict) and isinstance(response.get('data'),list):atomic(file,record)
30
+ response=record.get('response')
31
+ expected={(ALCHEMY[cid],address):(cid,address) for cid,address in batch}
32
+ for row in response.get('data',[]) if isinstance(response,dict) and isinstance(response.get('data'),list) else []:
33
+ if not isinstance(row,dict) or row.get('error') or not isinstance(row.get('network'),str) or not isinstance(row.get('address'),str):continue
34
+ identity=expected.get((row['network'],row['address'].lower()))
35
+ if identity is None or not isinstance(row.get('prices'),list):continue
36
+ price=dex_price({'token_address':identity[1],'indexer_price_references':[p for p in row['prices'] if isinstance(p,dict)]},None)
37
+ if price:prices[identity]={'chain_id':identity[0],'address':identity[1],**price}
38
+ return {'tokens':list(prices.values()),'status':'partial' if len(prices)<len(wanted) else 'checked'}
39
+
40
+
11
41
  def resolve(graph, prices):
12
42
  nodes={(r['chain_id'],r['address'].lower()):r for r in graph if r.get('address')}
13
43
  known={(r['chain_id'],r['address'].lower()):r for r in prices}
@@ -47,6 +77,8 @@ def resolve(graph, prices):
47
77
  'source':node['source'],'observed_at':node['observed_at'],'block_number':node['block_number'],
48
78
  'curve_price_in_reserve':node['price_in_reserve'],'curve_reserve':node['curve_reserve'],'reserve_symbol':node['reserve_symbol'],
49
79
  'price_path':[node['address']]+parent.get('price_path',[node['reserve_token']])}
80
+ if parent.get('retained_from_previous'):
81
+ record.update(retained_from_previous=True,latest_price_status='unavailable',observed_at=parent.get('observed_at'))
50
82
  cache[key]=record
51
83
  return record
52
84
  for key in nodes:visit(key,set())
@@ -69,31 +101,11 @@ def enrich(snapshot,market,folder,deadline=None):
69
101
  if recorded.get('market_pending'):raise MarketPause('Reserve checks reached the market time budget. Resume saved checks in Activity.')
70
102
  ordinary=[r for r in graph if r.get('address') and r.get('is_curve') is False and r['chain_id'] in ALCHEMY]
71
103
  prices=list(market.get('tokens',[]))
72
- rejected={(p['chain_id'],p['address'].lower()) for p in prices if p.get('quality')=='unreliable'}
73
- ordinary=[r for r in ordinary if (r['chain_id'],r['address'].lower()) not in rejected]
74
- # Only reserve assets are requested. Identify prices by network and contract, never symbol.
75
- requests=[{'network':ALCHEMY[r['chain_id']],'address':r['address']} for r in ordinary]
76
- inverse={value:key for key,value in ALCHEMY.items()}
77
- responses=[]
78
- key=h.secrets().get('ALCHEMY_CUSTOM_APY_KEY')
79
- for i in range(0,len(requests) if key else 0,25):
80
- import hashlib
81
- batch=requests[i:i+25];identity=hashlib.sha256(json.dumps(batch,sort_keys=True).encode()).hexdigest()
82
- file=folder/('reserve-price-cache-'+identity+'.json')
83
- record=json.loads(file.read_text()) if file.exists() else None
84
- if not record:
85
- response=fetch('https://api.g.alchemy.com/prices/v1/'+h.secrets()['ALCHEMY_CUSTOM_APY_KEY']+'/tokens/by-address',{'addresses':batch},deadline=deadline)
86
- record={'observed_at':h.now(),'source':'Alchemy token prices by address','request':batch,'response':response}
87
- if isinstance(response,dict) and isinstance(response.get('data'),list):atomic(file,record)
88
- response=record['response'];responses.append(record)
89
- for record in response.get('data') or []:
90
- p=next((p for p in record.get('prices',[]) if p.get('currency')=='usd' and p.get('value') is not None),None)
91
- if p and not record.get('error'):
92
- value=float(p['value'])
93
- if math.isfinite(value) and value>0:
94
- prices.append({'chain_id':inverse[record['network']],'address':record['address'],'usd':value,'basis':'Market index','quality':'estimated',
95
- 'observed_at':p.get('lastUpdatedAt'),'source':'https://www.alchemy.com/docs/data/prices-api/prices-api-endpoints/prices-api-endpoints/get-token-prices-by-address'})
96
- atomic(folder/'reserve-market-prices.json',responses)
104
+ settled={(p['chain_id'],p['address'].lower()) for p in prices
105
+ if p.get('usd') is not None or p.get('quality') in ('unfunded','unreliable')}
106
+ ordinary=[r for r in ordinary if (r['chain_id'],r['address'].lower()) not in settled]
107
+ indexed=index_prices([(r['chain_id'],r['address']) for r in ordinary],folder,deadline)
108
+ prices.extend(indexed['tokens'])
97
109
  def get(row):
98
110
  chain=DEX.get(row['chain_id'])
99
111
  if not chain:return None
@@ -94,6 +94,10 @@ global skill directory. Follow that host's configuration and approval flow.
94
94
  `holdings_query` for full-inventory counts, aggregates and rankings instead
95
95
  of asking the model to total a truncated page. Query results are bounded to
96
96
  25 per page; wallet and historical holdings pages allow up to 100.
97
+ - For price history, call `snapshots_list` with `kind: "prices"` (or `"all"`),
98
+ then use its IDs with `snapshot_read` and `snapshots_compare`. The default
99
+ remains holdings history. Each price observation uses its saved overlay and
100
+ linked holdings; later refreshes cannot replace those selected prices.
97
101
  - Report same-symbol ambiguity. Exact identities are chain ID plus contract,
98
102
  or `native` for a chain's native asset. Missing records are unknown, not zero.
99
103
  - `kira jobs wait ID --timeout 30 --json` waits up to the requested limit
package/docs/CLI.md CHANGED
@@ -1,9 +1,9 @@
1
1
  # CLI and provider configuration
2
2
 
3
- Kira 0.2.1 is a local wallet engine, CLI and web interface. Any capable agent
3
+ Kira is a local wallet engine, CLI and web interface. Any capable agent
4
4
  can use the CLI; MCP is optional. Your agent host supplies its own model,
5
5
  conversation, general tools and approvals. Kira does not start a model process.
6
- This branch prepares 0.2.1. Registry publication remains an operator action.
6
+ Version 0.2.2 is published on npm. This source prepares 0.2.3.
7
7
 
8
8
  ## Install and choose a workspace
9
9
 
@@ -35,7 +35,9 @@ use the supported CLI for a consistent data root.
35
35
 
36
36
  `--json` is accepted anywhere in the command. `info` returns the version,
37
37
  data directory, capabilities, connection readiness, saved-data revision, job
38
- status, UI availability and packaged skill path. Use `--help` for syntax.
38
+ status, UI availability and packaged skill path. Its `research_connection.status`
39
+ is `ready` when Alchemy is connected and `connection_required` before that.
40
+ `--data-dir` also works before or after the subcommand. Use `--help` for syntax.
39
41
 
40
42
  ## Open the local app
41
43
 
@@ -90,7 +92,8 @@ reject the import and preserve the configuration.
90
92
  RPC, discovery and import commands apply synchronously when idle. If research
91
93
  owns the writer, they return a queued receipt; `--json` always returns a queued
92
94
  receipt without waiting. Inspect the resulting job before reporting the
93
- configuration applied. Safe configuration output contains references, not values.
95
+ configuration applied; its `result.provider_configuration` lists the applied
96
+ modes and reference names. Safe configuration output contains references, not values.
94
97
 
95
98
  New configurations allow verified public RPC fallback. `--custom-only`
96
99
  disables fallback; `--public-fallback` restores it. Existing opt-outs persist.
@@ -122,7 +125,8 @@ kira prices 'Daily wallet' --idempotency-key prices-daily-001 --json
122
125
  ```
123
126
 
124
127
  The first tag is the display name. Exact names and registered addresses select
125
- wallets. Reuse an idempotency key only when retrying the same intended request.
128
+ wallets. `add` runs the first full holdings lookup itself; use `refresh` for
129
+ later updates. Reuse an idempotency key only when retrying the same intended request.
126
130
 
127
131
  `add`, `refresh` and `prices` persist a durable job before doing research.
128
132
  `--json` or `--enqueue` returns its receipt immediately and starts a separate
@@ -175,10 +179,24 @@ kira call holdings_query --arguments '{"mode":"search","query":"ETH"}' --json
175
179
  kira call wallet_read --arguments '{"wallet":"Daily wallet","offset":0,"limit":100}' --json
176
180
  kira call token_read --arguments '{"chain_id":8453,"address":"native"}' --json
177
181
  kira call snapshots_list --arguments '{"wallet":"Daily wallet","limit":20}' --json
178
- kira call job_list --arguments '{"state":"running","offset":0,"limit":20}' --json
182
+ kira call snapshots_list --arguments '{"wallet":"Daily wallet","kind":"prices","limit":20}' --json
183
+ kira call job_list --arguments '{"state":"active","offset":0,"limit":20}' --json
179
184
  kira ui context --json
180
185
  ```
181
186
 
187
+ `snapshots_list` defaults to holdings analyses. Use `kind: "prices"` for saved
188
+ price observations or `kind: "all"` for both. Pass the returned IDs to
189
+ `snapshot_read` or `snapshots_compare`. Price observations reproduce their saved
190
+ overlay and keep the linked holdings unchanged, even after another refresh.
191
+ Comparisons include price/value changes and native positions; a price change
192
+ is not a token transfer. No provider request is made by these reads.
193
+
194
+ `job_list` accepts `state` groups `all` (default), `active` (queued or
195
+ running), `succeeded` (succeeded or partial), `attention` (interrupted or
196
+ failed) and `cancelled`, or one exact state: `queued`, `running`, `partial`,
197
+ `interrupted` or `failed`. Add `wallet` to limit results to one registered
198
+ wallet.
199
+
182
200
  Follow returned `next_offset` values when complete pages are needed. Use
183
201
  `holdings_query` for server-computed ranking, counts and aggregates across the
184
202
  full inventory. Same-symbol tokens may differ by chain and contract; retain
package/docs/RELEASE.md CHANGED
@@ -1,6 +1,68 @@
1
1
  # Release preparation
2
2
 
3
- ## Prepared release: 0.2.1
3
+ ## Candidate 0.2.3
4
+
5
+ Research and price refresh share the Alchemy index fallback and DEX liquidity
6
+ checks. Insufficient or extremely one-sided DEX liquidity stays unpriced;
7
+ provider gaps retain usable prior observations with their original timestamps.
8
+ Cash-out classification uses the same DEX price validation as wallet valuation.
9
+ The symbol-based MEMBER pool override is removed, and a failed curve network
10
+ no longer discards observations from other networks.
11
+
12
+ History tools accept `snapshots_list` with `kind: "prices"` or `"all"`.
13
+ `snapshot_read` and `snapshots_compare` accept those immutable observation IDs,
14
+ with linked holdings, native positions, independent timestamps and unknowns.
15
+ Price-only refresh also saves evidence when run directly from Python.
16
+
17
+ OWS registration retries recover the same admitted job after interrupted
18
+ worker launch or receipt storage, including requests saved by 0.2.2. Previously
19
+ registered wallets keep their names, and cancelled jobs remain cancelled.
20
+ Unused embedded-agent completion parsing is removed. The website setup link
21
+ points to the currently published 0.2.2 guide until the next site update.
22
+
23
+ One independent review of the four audit PRs found a legacy OWS receipt recovery
24
+ gap, corrected with an upgrade regression. Final verification covers the full
25
+ suite, isolated installation, exact archive members, CLI/MCP price history and
26
+ source/history privacy checks. Synthetic checks do not claim live provider or
27
+ Gemini/Grok account acceptance.
28
+
29
+ Freeze the final archive from the validated, landed source and dry-run that
30
+ exact archive. Release publication and website deployment remain operator
31
+ actions. Preparation does not establish registry publication.
32
+
33
+ ## Published 0.2.2
34
+
35
+ Fixes two agent contract findings from the published 0.2.1 fresh-agent test.
36
+ `job_list` now publishes its accepted `state` values in the tool schema and
37
+ accepts each exact job state, such as `running`, as well as the existing
38
+ groups. Invalid values return the accepted list. Documented `kira call`
39
+ examples are checked against the published schemas. Global `--data-dir` now
40
+ works before or after a subcommand, like `--json`.
41
+
42
+ A fresh Claude Code test of this candidate found four more contract gaps, now
43
+ fixed. `research_connection.status` is `ready` only when Alchemy is connected
44
+ and `connection_required` before that, instead of always `configured`. A
45
+ finished settings job reports the settings it applied, not the ones it
46
+ replaced. Native rows in `wallet_read` keep `address: null`, like
47
+ `holdings_query`. `snapshots_list` rows carry `phase`, so progress saved during
48
+ a running lookup is distinct from finished lookups. The skill now explains
49
+ `kira add`, import side effects, per-operation progress, price refresh
50
+ history, float position values and `head_status`.
51
+
52
+ A second, read-only Claude Code check of the fixed candidate led to three
53
+ more changes. A partial price job now names its reasons in `result.gaps`.
54
+ `kira --help` shows the `kira` program name and a line for every command.
55
+ The skill corrects `head_status`, notes the native `address: null`, the
56
+ 100-event job window and older settings receipts. Wallet records, research,
57
+ financial projections and the web app are unchanged.
58
+
59
+ The operator published 0.2.2 on 2026-10-25. It was frozen from the landed
60
+ merge commit `4c15c69a7e7031a1f52e57d971927abe82fe85f8`. The registry archive is
61
+ byte-identical to the frozen one, SHA-256
62
+ `5d954fc1cb9e679d8f33c94f71721052315e589e4550540e8f5bda10e1ab5089`, and a fresh
63
+ registry install matched all 93 members. Review not selected.
64
+
65
+ ## Published 0.2.1
4
66
 
5
67
  Includes the external-agent architecture described in the preserved 0.2.0
6
68
  candidate below and four approved UI refinements. Short desktop windows can
@@ -9,16 +71,10 @@ one line, a single wallet card fills its available grid width, and token detail
9
71
  captions and addresses use larger, darker text. Wallet records, research,
10
72
  financial projections, CLI and MCP behavior are unchanged.
11
73
 
12
- Preserve the already frozen 0.2.0 archive and manifest. This 0.2.1 candidate is
13
- the next publication target; an unpublished earlier candidate is not overwritten.
14
- Before freezing, run the full repository suite, clean package installation,
15
- source and history privacy checks, responsive browser acceptance including short
16
- windows and multiple wallets, and exact source/archive/installed-member checks.
17
- Freeze from the accepted clean commit, dry-run the exact archive and leave
18
- registry publication to the operator. Review not selected.
19
-
20
- The separate real-device acceptance, fresh public website screenshots and site
21
- content update remain a follow-up after installation of the new version.
74
+ The operator published 0.2.1. Its archive SHA-256 is
75
+ `aed02bc57c0fec8eec8b8fe8539ddcc45592d7876f04b86045e1d136b51804e0`; the
76
+ installed package and all 93 members matched it. The real-device acceptance
77
+ and website update that followed installation are complete.
22
78
 
23
79
  ## Preserved candidate: 0.2.0
24
80
 
@@ -135,7 +191,7 @@ The earlier unpublished 0.1.2 candidate is preserved in the ignored
135
191
  `dist/preserved-0.1.2-bad8f02/` directory. Use the current `dist/release.json`
136
192
  and its matching archive for publication; do not mix manifests from candidates.
137
193
 
138
- Keep published GitHub tags, release assets and npm versions through 0.1.20
194
+ Keep published GitHub tags, release assets and npm versions through 0.2.2
139
195
  immutable. Do not rebuild them from later documentation commits. Future package
140
196
  changes need a new version. Release preparation does not publish a GitHub
141
197
  release, promote the website or publish to npm.
@@ -151,12 +207,12 @@ python3 scripts/prepare-release.py
151
207
  ```
152
208
 
153
209
  `prepare-release.py` scans the package boundary and creates
154
- `dist/kira-wallet-0.2.1.tgz`. The ignored `dist/release.json` records its source
210
+ `dist/kira-wallet-VERSION.tgz` for the version in `package.json`. The ignored `dist/release.json` records its source
155
211
  commit, SHA-256 and member count. It never publishes. After reviewing that
156
212
  manifest, the operator's final command from the project root is:
157
213
 
158
214
  ```sh
159
- npm publish ./dist/kira-wallet-0.2.1.tgz --access public
215
+ npm publish ./dist/kira-wallet-VERSION.tgz --access public
160
216
  ```
161
217
 
162
218
  Use the operator's npm account and complete authentication or OTP personally.
package/kira_cli.py CHANGED
@@ -186,48 +186,51 @@ def finish_configuration(store,job,*,enqueue=False):
186
186
  return 0
187
187
 
188
188
  def main():
189
- parser=argparse.ArgumentParser(description=__doc__)
189
+ parser=argparse.ArgumentParser(prog='kira',description=__doc__)
190
190
  parser.add_argument('--version',action='version',version=json.loads((ASSETS/'package.json').read_text())['version'])
191
191
  parser.add_argument('--json',action='store_true',help='Return structured output; research commands enqueue without waiting.')
192
- parser.add_argument('--data-dir',default=os.environ.get('KIRA_DATA_DIR',str(Path.home()/'.local/share/kira-wallet')))
192
+ parser.add_argument('--data-dir',default=os.environ.get('KIRA_DATA_DIR',str(Path.home()/'.local/share/kira-wallet')),help='Workspace directory. Accepted before or after the subcommand.')
193
193
  sub=parser.add_subparsers(dest='command',required=True)
194
- sub.add_parser('init');s=sub.add_parser('start');s.add_argument('--port',type=int,default=8787)
194
+ sub.add_parser('init',help='Create or check the data directory without changing records.');s=sub.add_parser('start',help='Open the local app.');s.add_argument('--port',type=int,default=8787)
195
195
  mode=s.add_mutually_exclusive_group()
196
196
  mode.add_argument('--read-only',dest='controls',action='store_false',help='View recorded holdings without local browser actions.')
197
197
  mode.add_argument('--controls',dest='controls',action='store_true',help=argparse.SUPPRESS)
198
198
  s.set_defaults(controls=True)
199
199
  s.add_argument('--no-open',action='store_true',help='Print the local app URL without opening a browser.')
200
- setup=sub.add_parser('setup');setup.add_argument('--port',type=int,default=8787);setup.add_argument('--no-open',action='store_true')
201
- sub.add_parser('stop');sub.add_parser('status');sub.add_parser('info')
202
- wallets=sub.add_parser('wallets');walletsub=wallets.add_subparsers(dest='wallet_action')
200
+ setup=sub.add_parser('setup',help='Open the local app at first-run setup.');setup.add_argument('--port',type=int,default=8787);setup.add_argument('--no-open',action='store_true')
201
+ sub.add_parser('stop',help='Stop the local app.');sub.add_parser('status',help='Show whether the local app is running.');sub.add_parser('info',help='Show version, data directory, readiness and active jobs.')
202
+ wallets=sub.add_parser('wallets',help='List wallets or change their names.');walletsub=wallets.add_subparsers(dest='wallet_action')
203
203
  tags=walletsub.add_parser('tags');tags.add_argument('wallet');tags.add_argument('--tag',action='append',required=True);tags.add_argument('--idempotency-key')
204
- tools=sub.add_parser('tools');tools.add_argument('--allow-writes',action='store_true')
205
- call=sub.add_parser('call');call.add_argument('tool');call.add_argument('--arguments',default='{}')
206
- sub.add_parser('skill')
207
- ui=sub.add_parser('ui');ui.add_argument('action',choices=['context'])
208
- archive=sub.add_parser('archive');archive.add_argument('action',choices=['list','read','workspaces','files','export']);archive.add_argument('conversation',nargs='?');archive.add_argument('--path');archive.add_argument('--output')
209
- connection=sub.add_parser('connect');connection.add_argument('provider',choices=['alchemy'])
210
- d=sub.add_parser('doctor');d.add_argument('--live',action='store_true');d.add_argument('--chains',default='1,8453,81457');d.add_argument('--budget',type=int,default=30)
211
- demo=sub.add_parser('demo');demo.add_argument('--port',type=int,default=8790)
212
- add=sub.add_parser('add');add.add_argument('address');add.add_argument('--tag',required=True);add.add_argument('--resume')
213
- refresh=sub.add_parser('refresh');refresh.add_argument('wallet');refresh.add_argument('--resume')
214
- prices=sub.add_parser('prices');prices.add_argument('wallet')
204
+ tools=sub.add_parser('tools',help='Serve Kira tools over MCP stdio.');tools.add_argument('--allow-writes',action='store_true')
205
+ call=sub.add_parser('call',help='Run one Kira tool with JSON arguments.');call.add_argument('tool');call.add_argument('--arguments',default='{}')
206
+ sub.add_parser('skill',help='Print the agent instructions.')
207
+ ui=sub.add_parser('ui',help="Read the app's last recorded selection.");ui.add_argument('action',choices=['context'])
208
+ archive=sub.add_parser('archive',help='Read earlier conversations and generated files.');archive.add_argument('action',choices=['list','read','workspaces','files','export']);archive.add_argument('conversation',nargs='?');archive.add_argument('--path');archive.add_argument('--output')
209
+ connection=sub.add_parser('connect',help='Enter an Alchemy key at a hidden local prompt.');connection.add_argument('provider',choices=['alchemy'])
210
+ d=sub.add_parser('doctor',help='Check RPC settings, optionally with live probes.');d.add_argument('--live',action='store_true');d.add_argument('--chains',default='1,8453,81457');d.add_argument('--budget',type=int,default=30)
211
+ demo=sub.add_parser('demo',help='Open a sample portfolio.');demo.add_argument('--port',type=int,default=8790)
212
+ add=sub.add_parser('add',help='Register a public wallet and run its first holdings lookup.');add.add_argument('address');add.add_argument('--tag',required=True);add.add_argument('--resume')
213
+ refresh=sub.add_parser('refresh',help='Run a new holdings lookup for a wallet.');refresh.add_argument('wallet');refresh.add_argument('--resume')
214
+ prices=sub.add_parser('prices',help='Refresh prices without changing balances.');prices.add_argument('wallet')
215
215
  for mutation in (add,refresh,prices):mutation.add_argument('--idempotency-key');mutation.add_argument('--enqueue',action='store_true')
216
- jobs=sub.add_parser('jobs');jobsub=jobs.add_subparsers(dest='action',required=True)
216
+ jobs=sub.add_parser('jobs',help='List, follow and control durable jobs.');jobsub=jobs.add_subparsers(dest='action',required=True)
217
217
  jobsub.add_parser('list');jobsub.add_parser('run')
218
218
  wait=jobsub.add_parser('wait');wait.add_argument('job_id');wait.add_argument('--timeout',type=float,default=30)
219
219
  for action in ('read','resume','cancel','deepen'):
220
220
  item=jobsub.add_parser(action);item.add_argument('job_id');item.add_argument('--idempotency-key')
221
- config=sub.add_parser('config');configsub=config.add_subparsers(dest='setting',required=True)
221
+ config=sub.add_parser('config',help='Show settings or import a key file reference.');configsub=config.add_subparsers(dest='setting',required=True)
222
222
  configsub.add_parser('show')
223
223
  imp=configsub.add_parser('import-env');imp.add_argument('--file',required=True);imp.add_argument('--key-env',default='ALCHEMY_API_KEY')
224
- rpc=sub.add_parser('rpc');rpcsub=rpc.add_subparsers(dest='setting',required=True)
224
+ rpc=sub.add_parser('rpc',help='Choose public or custom RPC references.');rpcsub=rpc.add_subparsers(dest='setting',required=True)
225
225
  rpcsub.add_parser('public')
226
226
  custom=rpcsub.add_parser('set');custom.add_argument('--chain',type=int,required=True);custom.add_argument('--url-env',required=True);custom.add_argument('--priority',choices=['public_first','custom_first']);fallback=custom.add_mutually_exclusive_group();fallback.add_argument('--public-fallback',action='store_true',default=None);fallback.add_argument('--custom-only',dest='public_fallback',action='store_false')
227
- discovery=sub.add_parser('discovery');discovery.add_argument('provider',choices=['none','alchemy']);discovery.add_argument('--key-env',default='ALCHEMY_API_KEY');discovery.add_argument('--explorers',action='store_true')
227
+ discovery=sub.add_parser('discovery',help='Choose the token discovery provider.');discovery.add_argument('provider',choices=['none','alchemy']);discovery.add_argument('--key-env',default='ALCHEMY_API_KEY');discovery.add_argument('--explorers',action='store_true')
228
228
  argv=sys.argv[1:]
229
- # A global machine-output switch works before or after a subcommand.
229
+ # Global switches work before or after a subcommand.
230
230
  if '--json' in argv:argv=['--json']+[a for a in argv if a!='--json']
231
+ for index,item in enumerate(argv):
232
+ if item=='--data-dir' and index+1<len(argv):argv=argv[index:index+2]+argv[:index]+argv[index+2:];break
233
+ if item.startswith('--data-dir='):argv=[item]+argv[:index]+argv[index+1:];break
231
234
  args=parser.parse_args(argv);root=Path(args.data_dir).expanduser().resolve();os.environ['KIRA_DATA_DIR']=str(root)
232
235
  if sys.version_info<(3,11):raise ValueError('Python 3.11 or newer is required.')
233
236
  if os.name!='posix':raise ValueError('This development build requires a POSIX host. macOS is verified.')
package/kira_config.py CHANGED
@@ -82,9 +82,10 @@ def public_status(root=None):
82
82
  rpc=cfg['rpc'];discovery=cfg['discovery']
83
83
  public=rpc['mode']=='public' or rpc['allow_public_fallback']
84
84
  import_command=f"kira config import-env --file PATH --key-env {discovery['key_env']}"
85
- return {'status':'configured','rpc_mode':rpc['mode'],'read_order':('custom_first' if discovery['provider']=='alchemy' else rpc.get('priority','public_first')) if public else 'custom_only',
85
+ ready=discovery['provider']=='alchemy' and available
86
+ return {'status':'ready' if ready else 'connection_required','rpc_mode':rpc['mode'],'read_order':('custom_first' if discovery['provider']=='alchemy' else rpc.get('priority','public_first')) if public else 'custom_only',
86
87
  'custom_chain_count':len(rpc['chains']) if rpc['mode']=='custom' else 0,
87
- 'discovery_provider':discovery['provider'],'discovery_key_available':discovery['provider']=='alchemy' and available,
88
+ 'discovery_provider':discovery['provider'],'discovery_key_available':ready,
88
89
  'key_env':discovery['key_env'],'public_discovery':False,'required_provider':'alchemy',
89
90
  'import_command':import_command,
90
91
  'guide':'Open Workspace settings > Alchemy. Run kira connect alchemy in a terminal and enter the app key at its hidden prompt. Return to Kira to check the connection, then refresh holdings. Actual results establish network access. Never enter or request a key in chat or the browser.',
package/kira_jobs.py CHANGED
@@ -35,6 +35,10 @@ def signal_process_group(pgid, sig):
35
35
  State = Literal['queued', 'running', 'interrupted', 'succeeded', 'partial', 'failed', 'cancelled']
36
36
  Operation = Literal['wallet.add','wallet.refresh','prices.refresh','wallet.setTags','settings.rpc','settings.discovery','settings.provider','settings.importEnv']
37
37
  TERMINAL = {'succeeded', 'partial', 'failed', 'cancelled', 'interrupted'}
38
+ # Web history uses the groups. Agents may also filter by an exact state they read on a job.
39
+ STATE_FILTERS = {'all': None, 'active': {'queued','running'}, 'succeeded': {'succeeded','partial'},
40
+ 'attention': {'interrupted','failed'}, 'cancelled': {'cancelled'},
41
+ **{state: {state} for state in ('queued','running','partial','interrupted','failed')}}
38
42
  ADDRESS = re.compile(r'0x[0-9a-fA-F]{40}')
39
43
 
40
44
  class Request(TypedDict):
@@ -133,6 +137,11 @@ def fields(value, required):
133
137
  if not isinstance(value, dict) or set(value) != set(required):
134
138
  raise JobError('invalid_input', 'Unexpected or missing operation fields.')
135
139
 
140
+ def provider_summary(config):
141
+ """Settings facts and environment names only, never values."""
142
+ return {'rpc_mode':config['rpc']['mode'],'discovery_provider':config['discovery']['provider'],
143
+ 'public_fallback':config['rpc']['allow_public_fallback'],'endpoint_refs':{cid:row['url_env'] for cid,row in config['rpc']['chains'].items()}}
144
+
136
145
  def identifier(value):
137
146
  if not isinstance(value, str): raise JobError('invalid_id', 'Expected a job ID.')
138
147
  try:
@@ -238,12 +247,10 @@ class JobStore:
238
247
  def page(self, offset=0, limit=50, state='all', wallet=None):
239
248
  from kira_state import page_bounds
240
249
  page_bounds(offset, limit)
241
- groups = {'all': None, 'active': {'queued','running'}, 'succeeded': {'succeeded','partial'},
242
- 'attention': {'interrupted','failed'}, 'cancelled': {'cancelled'}}
243
- if state not in groups:raise JobError('invalid_state','Choose a supported job state filter.')
250
+ if state not in STATE_FILTERS:raise JobError('invalid_state','Choose a job state filter: '+', '.join(STATE_FILTERS)+'.')
244
251
  selected = self.wallet(wallet)['address_key'] if wallet else None
245
252
  with self.locked():
246
- rows = [r for r in self.summaries() if (groups[state] is None or r['state'] in groups[state])
253
+ rows = [r for r in self.summaries() if (STATE_FILTERS[state] is None or r['state'] in STATE_FILTERS[state])
247
254
  and (selected is None or (r['input'].get('wallet') or r['input'].get('address','')).lower() == selected)]
248
255
  rows.sort(key=lambda r: r['created_at'], reverse=True)
249
256
  jobs = [self.get(r['job_id']) for r in rows[offset:offset+limit]]
@@ -483,8 +490,21 @@ class JobStore:
483
490
  path = (self.root / snapshot_id).resolve()
484
491
  if not path.is_relative_to((self.root / 'snapshots').resolve()) or '..' in Path(snapshot_id).parts:
485
492
  raise JobError('invalid_snapshot', 'Snapshot ID is outside saved evidence.')
486
- if not (path / 'results.json').is_file(): raise JobError('not_found', 'Snapshot was not found.')
487
- result = read(path / 'results.json')
493
+ if (path / 'results.json').is_file():result = read(path / 'results.json')
494
+ elif (path / 'prices.json').is_file():
495
+ overlay=read(path/'prices.json')
496
+ source=overlay.get('snapshot_id')
497
+ if not isinstance(source,str) or not source.startswith('snapshots/') or '..' in Path(source).parts:
498
+ raise JobError('invalid_snapshot','Invalid holdings source for this price observation.')
499
+ holdings=(self.root/source/'results.json').resolve()
500
+ if not holdings.is_relative_to((self.root/'snapshots').resolve()) or not holdings.is_file():
501
+ raise JobError('invalid_snapshot','Price observations require a saved holdings source.')
502
+ result=read(holdings)
503
+ if overlay.get('wallet_address','').lower()!=result.get('wallet_address','').lower():
504
+ raise JobError('wallet_mismatch','Price observation and holdings must belong to the same wallet.')
505
+ result={**result,'price_observation':{'snapshot_id':snapshot_id,'holdings_snapshot':source,'observed_at':overlay.get('observed_at')},
506
+ 'price_overlay':{k:overlay[k] for k in ('observed_at','wallet_address','tokens','native_usd','markets','sell_quotes') if k in overlay}}
507
+ else:raise JobError('not_found', 'Snapshot was not found.')
488
508
  # Public readers receive financial facts, never arbitrary raw provider files.
489
509
  from kira_config import redact
490
510
  def safe(value, key=''):
@@ -503,8 +523,22 @@ class JobStore:
503
523
  from decimal import Decimal, InvalidOperation, localcontext
504
524
  old, new = self.snapshot(before), self.snapshot(after)
505
525
  if old['wallet_address'].lower() != new['wallet_address'].lower():raise JobError('wallet_mismatch','Choose snapshots from the same wallet.')
526
+ price_comparison=bool(old.get('price_observation') or new.get('price_observation'))
527
+ valuations=[]
528
+ if price_comparison:
529
+ sys.path.insert(0,str(ASSETS/'viewer'))
530
+ from model import project_wallet
531
+ entry=self.wallet(old['wallet_address'])
532
+ for identity,snapshot in ((before,old),(after,new)):
533
+ source=(snapshot.get('price_observation') or {}).get('holdings_snapshot',identity)
534
+ projected=project_wallet({**entry,'latest_snapshot':{'directory':source}},snapshot,self.root,market=snapshot.get('price_overlay'))
535
+ valuations.append({a['id']:a for a in projected['assets']})
506
536
  def positions(snapshot):
507
- return {(t['chain_id'],t['token_address'].lower(),t.get('token_id')):t for t in snapshot.get('tokens',[])}
537
+ rows={(t['chain_id'],t['token_address'].lower(),t.get('token_id')):t for t in snapshot.get('tokens',[])}
538
+ if price_comparison:
539
+ rows.update({(c['chain_id'],'native',None):{'symbol':c.get('native_symbol'),'wallet_balance':c.get('native_balance')}
540
+ for c in snapshot.get('coverage',[])})
541
+ return rows
508
542
  left,right=positions(old),positions(new);changes=[]
509
543
  for identity in sorted(left.keys() | right.keys(),key=str):
510
544
  a,b=left.get(identity),right.get(identity);delta=None
@@ -523,6 +557,13 @@ class JobStore:
523
557
  'presence':'both' if a and b else 'new_record' if b else 'absent_record',
524
558
  'price_references_changed':(a or {}).get('indexer_price_references')!=(b or {}).get('indexer_price_references'),
525
559
  'valuation_method_changed':bool((a or {}).get('mintclub'))!=bool((b or {}).get('mintclub'))})
560
+ if price_comparison:
561
+ first,last=(values.get(str(identity[0])+':'+identity[1],{}) for values in valuations)
562
+ def price(asset):
563
+ return {k:asset['price'].get(k) for k in ('usd','basis','quality','observed_at')} if asset.get('price') else None
564
+ changes[-1].update(before_price=price(first),after_price=price(last),
565
+ before_value_usd=first.get('value_usd'),after_value_usd=last.get('value_usd'),
566
+ price_references_changed=price(first)!=price(last))
526
567
  def counts(rows):
527
568
  return {'position_count':len(rows),
528
569
  'new_record_count':sum(r['presence']=='new_record' for r in rows),
@@ -532,20 +573,36 @@ class JobStore:
532
573
  'unchanged_observation_count':sum(r['balance_comparison']=='unchanged_observations' for r in rows),
533
574
  'different_observation_count':sum(r['balance_comparison']=='different_observations' for r in rows),
534
575
  'unknown_comparison_count':sum(r['balance_comparison']=='unknown' for r in rows)}
535
- summary={'scope':'full_recorded_non_native_token_comparison_before_pagination',
576
+ summary={'scope':'full_recorded_price_comparison_before_pagination' if price_comparison else 'full_recorded_non_native_token_comparison_before_pagination',
536
577
  'totals':counts(changes),
537
578
  'chains':[{'chain_id':cid,**counts([r for r in changes if r['chain_id']==cid])}
538
579
  for cid in sorted({r['chain_id'] for r in changes})]}
539
580
  return {'schema_version':1,'before':before,'after':after,'wallet':new['wallet_address'],
540
- 'coverage_changed':old.get('coverage')!=new.get('coverage'),'price_references_changed':old.get('price_references')!=new.get('price_references'),
541
- 'comparison_summary':summary,'positions':changes,'note':'Absent records are unknown, not zero. Missing prior or later observations cannot establish or rule out receipts, disposals or transfers. Broader discovery can explain visibility, but activity and its cause remain unknown without transfer history. Even equal observed balances do not exclude intervening activity. Price overlays have separate observations and are not balance changes. Coverage changes compare full evidence, including observation times and blocks; this alone does not prove the set of checked networks changed. Positions compare direct token records. Read both snapshots to compare native balances.'}
581
+ 'coverage_changed':old.get('coverage')!=new.get('coverage'),'price_references_changed':old.get('price_references')!=new.get('price_references') or price_comparison and any(r['price_references_changed'] for r in changes),
582
+ 'comparison_summary':summary,'positions':changes,'note':'Absent records are unknown, not zero. Missing prior or later observations cannot establish or rule out receipts, disposals or transfers. Broader discovery can explain visibility, but activity and its cause remain unknown without transfer history. Even equal observed balances do not exclude intervening activity. Price overlays have separate observations and are not balance changes. Coverage changes compare full evidence, including observation times and blocks; this alone does not prove the set of checked networks changed. ' + ('Positions include native assets and the selected recorded price observations.' if price_comparison else 'Positions compare direct token records. Read both snapshots to compare native balances.')}
542
583
 
543
- def snapshots(self, wallet):
584
+ def snapshots(self, wallet, kind='holdings'):
585
+ if kind not in ('holdings','prices','all'):raise JobError('invalid_snapshot_kind','Choose holdings, prices or all.')
544
586
  key = self.wallet(wallet)['address_key']
545
587
  paths = {w['latest_snapshot']['directory'] for w in read(self.root / 'wallets.json')['wallets'] if w['address_key'] == key and w.get('latest_snapshot')}
546
588
  for run in read(self.root / 'wallets.json').get('research_runs', []):
547
589
  if run['address_key'] == key: paths.add(run['snapshot'])
548
- return sorted([{'snapshot_id': p, 'compiled_at': self.snapshot(p).get('compiled_at'), 'status': self.snapshot(p).get('status')} for p in paths],key=lambda r:r['compiled_at'] or '')
590
+ rows = []
591
+ for path in paths if kind!='prices' else []:
592
+ # phase "initial" marks progress published while a holdings lookup was still running.
593
+ snapshot = self.snapshot(path)
594
+ rows.append({'snapshot_id': path, 'compiled_at': snapshot.get('compiled_at'), 'status': snapshot.get('status'),
595
+ 'phase': (snapshot.get('pipeline') or {}).get('phase')})
596
+ if kind!='holdings':
597
+ evidence=set((self.root/'snapshots/jobs').glob('*/prices.json'))
598
+ for path in paths:evidence.update((self.root/path/'price-evidence').glob('*/prices.json'))
599
+ for file in evidence:
600
+ if not file.resolve().is_relative_to((self.root/'snapshots').resolve()):continue
601
+ overlay=read(file)
602
+ if overlay.get('wallet_address','').lower()!=key or overlay.get('snapshot_id') not in paths:continue
603
+ rows.append({'snapshot_id':str(file.parent.relative_to(self.root)),'compiled_at':overlay.get('observed_at'),
604
+ 'status':overlay.get('job_result_status'),'phase':'prices','holdings_snapshot':overlay['snapshot_id']})
605
+ return sorted(rows, key=lambda r: (r['compiled_at'] or '',r['snapshot_id']))
549
606
 
550
607
  def publication(self, job):
551
608
  if job['operation'].startswith('settings.') or job['operation']=='wallet.setTags':
@@ -568,7 +625,8 @@ class JobStore:
568
625
  previous = read(projection) if projection.exists() else {}
569
626
  value = read(overlay)
570
627
  if (previous.get('observed_at') or '') <= value['observed_at']: atomic(projection, value)
571
- return {'snapshot_id': item['snapshot_id'], 'overlay': item['overlay'], 'status': item['status']}
628
+ return {'snapshot_id': item['snapshot_id'], 'overlay': item['overlay'], 'status': item['status'],
629
+ **({'gaps': value['job_result_gaps']} if isinstance(value.get('job_result_gaps'), list) else {})}
572
630
  manifest = folder / 'run.json'
573
631
  if not manifest.exists() or not (folder / 'results.json').exists(): return None
574
632
  result = read(folder / 'results.json')
@@ -710,7 +768,8 @@ class JobStore:
710
768
  if job['input']['provider']=='alchemy' and not secret_values(config).get(job['input']['key_env']):
711
769
  raise JobError('connection_key_missing','Kira cannot find the local Alchemy key. Follow the connection guide, then retry. Existing settings are unchanged.')
712
770
  atomic(config_path(),config)
713
- result={'status':'completed','setting':job['operation']};atomic(self.jobs/'results'/(job['job_id']+'.json'),result);return result
771
+ result={'status':'completed','setting':job['operation'],'provider_configuration':provider_summary(config)}
772
+ atomic(self.jobs/'results'/(job['job_id']+'.json'),result);return result
714
773
  self.require_connection()
715
774
  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'])}
716
775
  command=self.engine_command(job)
@@ -815,10 +874,10 @@ class JobStore:
815
874
  if entry.get('research_generation') not in (None,job.get('parent_job_id')):
816
875
  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
817
876
  if job:
818
- from kira_config import load_config
819
- config=load_config()
820
- job['provider_configuration']={'rpc_mode':config['rpc']['mode'],'discovery_provider':config['discovery']['provider'],
821
- 'public_fallback':config['rpc']['allow_public_fallback'],'endpoint_refs':{cid:row['url_env'] for cid,row in config['rpc']['chains'].items()}}
877
+ # Research records the settings it ran with. Settings jobs report their result instead.
878
+ if not job['operation'].startswith('settings.'):
879
+ from kira_config import load_config
880
+ job['provider_configuration']=provider_summary(load_config())
822
881
  job.update(state='running', stage='starting', started_at=now(), attempt=job['attempt'] + 1, errors=[]); self.save(job)
823
882
  if not job:
824
883
  time.sleep(.5)