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 +4 -2
- package/cashout.py +2 -0
- package/curve_pricing.py +38 -26
- package/docs/AGENT_TOOLS.md +4 -0
- package/docs/CLI.md +24 -6
- package/docs/RELEASE.md +70 -14
- package/kira_cli.py +25 -22
- package/kira_config.py +3 -2
- package/kira_jobs.py +77 -18
- package/kira_ows.py +9 -3
- package/kira_records.py +1 -161
- package/kira_research_tools.py +7 -6
- package/kira_service.py +11 -4
- package/market_records.py +25 -0
- package/package.json +2 -2
- package/sell_quotes.py +1 -1
- package/skills/kira-wallet/SKILL.md +44 -5
- package/viewer/model.py +3 -27
- package/viewer/refresh-curve-prices.cjs +11 -21
- package/viewer/refresh_prices.py +33 -24
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
|
-
**
|
|
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.
|
|
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
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
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
|
package/docs/AGENT_TOOLS.md
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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.
|
|
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
|
|
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.
|
|
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
|
|
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
|
-
##
|
|
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
|
-
|
|
13
|
-
the
|
|
14
|
-
|
|
15
|
-
|
|
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.
|
|
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-
|
|
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-
|
|
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
|
-
#
|
|
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
|
-
|
|
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':
|
|
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
|
-
|
|
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 (
|
|
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
|
|
487
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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']
|
|
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
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
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)
|