@atmem/architect 0.2.0-alpha.4.1 → 0.2.1
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/LICENSE +202 -0
- package/NOTICE +6 -0
- package/README.md +21 -21
- package/cli/runtime.mjs +3 -1
- package/dist/assets/{index-qtCM2D7O.js → index-D4CZ_n8f.js} +19166 -19094
- package/dist/assets/index-DuveVTIx.css +1 -0
- package/dist/assets/{index.es-C6C0sneB.js → index.es-Fcqg-orY.js} +1 -1
- package/dist/assets/{visual-export-DjbjgQbA.js → visual-export-4PDDTAP7.js} +3 -3
- package/dist/index.html +2 -2
- package/dist/licenses/atmem-APACHE-2.0.txt +202 -0
- package/dist/licenses/memory-dependency-notices.txt +149 -2241
- package/package.json +3 -3
- package/public/licenses/atmem-APACHE-2.0.txt +202 -0
- package/public/licenses/memory-dependency-notices.txt +149 -2241
- package/server/app.py +1 -1
- package/server/codex_app_server.py +5 -5
- package/server/codex_worker_policy.py +27 -4
- package/server/engineering_artifacts.py +7 -1
- package/server/engineering_handoff.py +9 -0
- package/server/engineering_routes.py +16 -2
- package/server/local_providers.py +7 -3
- package/server/memory-requirements.lock +6 -6
- package/server/memory-requirements.txt +2 -2
- package/server/memory_context.py +58 -0
- package/server/memory_routes.py +67 -8
- package/server/memory_service.py +40 -5
- package/server/memory_worker.py +76 -11
- package/server/orca_handoff.py +90 -0
- package/server/pricing.py +161 -1
- package/server/prompts.py +13 -0
- package/server/request_uploads.py +65 -0
- package/server/terraform_routes.py +47 -1
- package/server/windows_snapshot.py +119 -0
- package/src/ArchitectureWorkPanel.jsx +0 -7
- package/src/ArtifactChecks.jsx +2 -2
- package/src/ChatMessage.jsx +12 -2
- package/src/EngagementViews.jsx +18 -16
- package/src/EngineeringPanel.jsx +35 -28
- package/src/FileTree.jsx +7 -0
- package/src/FinalChecklist.jsx +23 -0
- package/src/HelpNote.jsx +3 -0
- package/src/ImplementationDelivery.jsx +56 -17
- package/src/JourneyViews.jsx +20 -17
- package/src/LocationChoices.jsx +12 -0
- package/src/MemoryPanel.jsx +43 -31
- package/src/OpenAIVoicePanel.jsx +15 -14
- package/src/OutputsView.jsx +28 -16
- package/src/ProjectChecks.jsx +58 -0
- package/src/StarPrompt.jsx +26 -0
- package/src/WorkbenchDialogs.jsx +1 -1
- package/src/conversation.mjs +1 -0
- package/src/delivery.mjs +1 -1
- package/src/engineering.css +10 -0
- package/src/estimate.mjs +5 -3
- package/src/final-package.mjs +34 -0
- package/src/journey-workspace.css +39 -0
- package/src/journey.mjs +12 -2
- package/src/large-request.mjs +25 -0
- package/src/main.jsx +129 -98
- package/src/output-bundle.mjs +18 -4
- package/src/pricing.mjs +59 -3
- package/src/project-file.mjs +1 -1
- package/src/useChatScroll.jsx +5 -4
- package/src/useEngineeringStatus.mjs +17 -0
- package/src/validation-receipt.mjs +1 -1
- package/dist/assets/index-B97MkVMs.css +0 -1
package/server/memory_worker.py
CHANGED
|
@@ -2,13 +2,70 @@
|
|
|
2
2
|
import json
|
|
3
3
|
import sys
|
|
4
4
|
import sqlite3
|
|
5
|
+
import base64
|
|
6
|
+
import re
|
|
5
7
|
from importlib.metadata import version
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
EXPECTED_ATMEM_VERSION = re.search(r"^atmem==(\d+\.\d+\.\d+)$", (Path(__file__).parent / "memory-requirements.txt").read_text(), re.M).group(1)
|
|
6
10
|
from atmem import Memory
|
|
7
11
|
|
|
8
12
|
|
|
13
|
+
def public_record(record, owner=None, memory=None):
|
|
14
|
+
episode=memory.store.get_episode(record['subject_id'],record['episode_id'])if memory and record.get('episode_id')else None
|
|
15
|
+
raw=(episode or {}).get('raw') or record.get('raw') or {}
|
|
16
|
+
return {**{key:record.get(key)for key in ('id','content','status','fact_key','created_at','updated_at','supersedes_id','source_type')},
|
|
17
|
+
'governance':{'userId':raw.get('user_id'), 'actor':raw.get('actor'), 'actorType':raw.get('actor_type'),
|
|
18
|
+
'source':raw.get('source_url') or raw.get('interpreter'), 'ownerUserId':owner}}
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def page_records(memory,subject,op):
|
|
22
|
+
# Pinned SDK adapter: use its policy-aware connection, never reopen the database.
|
|
23
|
+
from atmem.store.sqlite import _record_from_row
|
|
24
|
+
db=memory.store._conn
|
|
25
|
+
limit=max(1,min(int(op.get('limit',50)),100));query=str(op.get('query','')).strip()[:200]
|
|
26
|
+
status=op.get('status','active')
|
|
27
|
+
statuses=('active','superseded','quarantined') if status=='all' else (status,)
|
|
28
|
+
if any(value not in {'active','superseded','quarantined'}for value in statuses):raise ValueError('Invalid memory status')
|
|
29
|
+
before=None
|
|
30
|
+
if op.get('cursor'):
|
|
31
|
+
before=json.loads(base64.urlsafe_b64decode(op['cursor']).decode())
|
|
32
|
+
if not isinstance(before,list)or len(before)!=2 or any(not isinstance(v,str)or len(v)>100 for v in before):raise ValueError('Invalid page cursor')
|
|
33
|
+
candidates=[];total=0
|
|
34
|
+
for selected in statuses:
|
|
35
|
+
clauses=['subject_id=?','status=?'];args=[subject,selected]
|
|
36
|
+
if query:
|
|
37
|
+
if query.startswith('rec_'):
|
|
38
|
+
clauses.append('id=?');args.append(query)
|
|
39
|
+
else:
|
|
40
|
+
terms=re.findall(r'[^\W_]+',query,flags=re.UNICODE)[:12]
|
|
41
|
+
if not terms:raise ValueError('Enter a search word or record ID')
|
|
42
|
+
if not memory.store._fts_enabled:raise ValueError('Indexed search is unavailable for this store')
|
|
43
|
+
expression=' AND '.join('"'+word.replace('"','')+'"'for word in terms)
|
|
44
|
+
clauses.append('id IN (SELECT record_id FROM records_fts WHERE records_fts MATCH ? AND subject_id=?)')
|
|
45
|
+
args.extend([expression,subject])
|
|
46
|
+
where=' AND '.join(clauses)
|
|
47
|
+
total+=db.execute('SELECT COUNT(*) FROM records WHERE '+where,args).fetchone()[0]
|
|
48
|
+
if before:where+=' AND (created_at,id)<(?,?)';args.extend(before)
|
|
49
|
+
rows=db.execute('SELECT * FROM records WHERE '+where+' ORDER BY created_at DESC,id DESC LIMIT ?',(*args,limit+1)).fetchall()
|
|
50
|
+
candidates.extend(_record_from_row(row)for row in rows)
|
|
51
|
+
candidates.sort(key=lambda row:(row['created_at'],row['id']),reverse=True)
|
|
52
|
+
more=len(candidates)>limit;rows=candidates[:limit]
|
|
53
|
+
cursor=base64.urlsafe_b64encode(json.dumps([rows[-1]['created_at'],rows[-1]['id']]).encode()).decode()if more and rows else None
|
|
54
|
+
return {'records':[public_record(row,op.get('owner'),memory)for row in rows],'total':total,'hasMore':more,'nextCursor':cursor,'limit':limit}
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def keyed_records(memory,subject,record_id,limit=10001):
|
|
58
|
+
record=memory.store.get_record(subject,record_id)
|
|
59
|
+
if not record or record['status']=='tombstoned':raise ValueError('Memory is unavailable in this scope')
|
|
60
|
+
if not record.get('fact_key'):return record,[record]
|
|
61
|
+
from atmem.store.sqlite import _record_from_row
|
|
62
|
+
rows=memory.store._conn.execute("SELECT * FROM records WHERE subject_id=? AND fact_key IS ? AND status IN ('active','superseded','quarantined') ORDER BY created_at DESC,id DESC LIMIT ?",(subject,record.get('fact_key'),limit)).fetchall()
|
|
63
|
+
return record,[_record_from_row(row)for row in rows]
|
|
64
|
+
|
|
65
|
+
|
|
9
66
|
def run(payload):
|
|
10
|
-
if version('atmem') !=
|
|
11
|
-
raise RuntimeError('AtMem AI Architect requires the tested AtMem
|
|
67
|
+
if version('atmem') != EXPECTED_ATMEM_VERSION:
|
|
68
|
+
raise RuntimeError(f'AtMem AI Architect requires the tested AtMem {EXPECTED_ATMEM_VERSION} runtime')
|
|
12
69
|
memory = Memory(payload['database'], auto_vectors=False)
|
|
13
70
|
receipts = sqlite3.connect(payload['database'] + '.receipts.sqlite')
|
|
14
71
|
receipts.execute('CREATE TABLE IF NOT EXISTS receipts (id TEXT PRIMARY KEY, result TEXT)')
|
|
@@ -23,32 +80,40 @@ def run(payload):
|
|
|
23
80
|
results.append(json.loads(previous[0]))
|
|
24
81
|
continue
|
|
25
82
|
value = memory.remember(subject, message=op['text'], source_type='user_message',
|
|
26
|
-
interpreted_fact=op['text'], interpreted_fact_key=op['key'], actor='atforma',
|
|
27
|
-
raw={'interpreter': 'atforma-project-event-v1', 'event_id': op['eventId'], 'source_url': op.get('source', '')})
|
|
83
|
+
interpreted_fact=op['text'], interpreted_fact_key=op['key'], actor=op.get('actor','atforma'),
|
|
84
|
+
raw={'interpreter': 'atforma-project-event-v1', 'event_id': op['eventId'], 'source_url': op.get('source', ''), 'user_id':op.get('owner'), 'actor':op.get('actor'), 'actor_type':op.get('actorType')})
|
|
28
85
|
receipt = {'refused': value.get('refused', []), 'ids': [r['id'] for r in value.get('records', [])] + value.get('duplicate_ids', [])}
|
|
29
86
|
receipts.execute('INSERT INTO receipts VALUES (?,?)', (op['eventId'], json.dumps(receipt)))
|
|
30
87
|
receipts.commit()
|
|
31
88
|
results.append(receipt)
|
|
32
89
|
elif action == 'recall':
|
|
33
90
|
results.append(memory.recall(subject, op['query'], limit=op.get('limit', 6)))
|
|
91
|
+
elif action == 'page':
|
|
92
|
+
results.append(page_records(memory,subject,op))
|
|
93
|
+
elif action == 'record':
|
|
94
|
+
record=memory.store.get_record(subject,op['recordId'])
|
|
95
|
+
if not record or record['status']=='tombstoned':raise ValueError('Record unavailable in this scope')
|
|
96
|
+
audit=memory.store._conn.execute('SELECT actor,event_type,created_at FROM audit_log WHERE subject_id=? AND record_id=? ORDER BY created_at DESC,sequence DESC LIMIT 50',(subject,op['recordId'])).fetchall()
|
|
97
|
+
results.append({'record':public_record(record,op.get('owner'),memory),'audit':[dict(row)for row in audit]})
|
|
98
|
+
elif action == 'forget-preview':
|
|
99
|
+
record,rows=keyed_records(memory,subject,op['recordId'],101)
|
|
100
|
+
total=memory.store._conn.execute("SELECT COUNT(*) FROM records WHERE subject_id=? AND fact_key IS ? AND status!='tombstoned'",(subject,record.get('fact_key'))).fetchone()[0]if record.get('fact_key')else 1
|
|
101
|
+
results.append({'records':[public_record(row,op.get('owner'),memory)for row in rows[:100]],'total':total})
|
|
34
102
|
elif action == 'list':
|
|
35
103
|
results.append(memory.list(subject, include_inactive=True))
|
|
36
104
|
elif action == 'forget':
|
|
37
105
|
# Forget the entire keyed preference history so old values cannot resurface.
|
|
38
|
-
records
|
|
39
|
-
|
|
40
|
-
if record is None:
|
|
41
|
-
raise ValueError('Memory record is not available in this scope')
|
|
106
|
+
record,records=keyed_records(memory,subject,op['recordId'])
|
|
107
|
+
if len(records)>10000:raise ValueError('History exceeds the synchronous removal limit; no records were removed')
|
|
42
108
|
deletion_receipts = []
|
|
43
109
|
for r in records:
|
|
44
|
-
|
|
45
|
-
deletion_receipts.append(memory.forget_record(subject, r['id'])['receipt'])
|
|
110
|
+
deletion_receipts.append(memory.forget_record(subject,r['id'],actor=op.get('actor','user'))['receipt'])
|
|
46
111
|
results.append({'deleted': True, 'receipts': deletion_receipts, 'key': record.get('fact_key')})
|
|
47
112
|
elif action == 'audit':
|
|
48
113
|
report = memory.audit(subject)
|
|
49
114
|
results.append({'valid': report['audit_chain_valid'], 'events': report['audit_log'][-40:]})
|
|
50
115
|
elif action == 'log':
|
|
51
|
-
results.append(memory.log_action(subject, op['eventType'], op['payload'], actor='
|
|
116
|
+
results.append(memory.log_action(subject, op['eventType'], op['payload'], actor='atmem-architect:'+op.get('owner','unknown')))
|
|
52
117
|
else:
|
|
53
118
|
raise ValueError('Unknown memory operation')
|
|
54
119
|
return {'version': version('atmem'), 'results': results}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
"""Explicit local editor handoff. Never executes project files or overwrites a workspace."""
|
|
2
|
+
import base64
|
|
3
|
+
import binascii
|
|
4
|
+
import io
|
|
5
|
+
import os
|
|
6
|
+
from pathlib import Path, PurePosixPath
|
|
7
|
+
import re
|
|
8
|
+
import shutil
|
|
9
|
+
import stat
|
|
10
|
+
import subprocess
|
|
11
|
+
import sys
|
|
12
|
+
import tempfile
|
|
13
|
+
import zipfile
|
|
14
|
+
|
|
15
|
+
MAX_ZIP = 32_000_000
|
|
16
|
+
MAX_EXPANDED = 150_000_000
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def orca_command():
|
|
20
|
+
names = ['orca-ide', 'orca'] if sys.platform.startswith('linux') else ['orca']
|
|
21
|
+
for name in names:
|
|
22
|
+
found = shutil.which(name)
|
|
23
|
+
if found:
|
|
24
|
+
return found
|
|
25
|
+
candidate = Path.home()/'.local/bin/orca'
|
|
26
|
+
if candidate.is_file() and os.access(candidate, os.X_OK):
|
|
27
|
+
return str(candidate)
|
|
28
|
+
raise ValueError('Install Orca and enable its CLI in Settings → General → Orca CLI.')
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def unpack_package(encoded, root, project):
|
|
32
|
+
try:
|
|
33
|
+
data = base64.b64decode(encoded, validate=True)
|
|
34
|
+
except (ValueError, binascii.Error):
|
|
35
|
+
raise ValueError('The project ZIP is invalid.') from None
|
|
36
|
+
if not data or len(data) > MAX_ZIP:
|
|
37
|
+
raise ValueError('Orca handoff supports ZIPs up to 32 MB. Download ZIP instead.')
|
|
38
|
+
try:
|
|
39
|
+
archive = zipfile.ZipFile(io.BytesIO(data))
|
|
40
|
+
entries = archive.infolist()
|
|
41
|
+
if len(entries) > 20000 or sum(e.file_size for e in entries) > MAX_EXPANDED:
|
|
42
|
+
raise ValueError('The expanded project exceeds the handoff limit.')
|
|
43
|
+
seen = set()
|
|
44
|
+
for entry in entries:
|
|
45
|
+
name = entry.filename
|
|
46
|
+
parts = PurePosixPath(name).parts
|
|
47
|
+
mode = entry.external_attr >> 16
|
|
48
|
+
if (not parts or name.startswith('/') or '\\' in name or '\x00' in name
|
|
49
|
+
or any(part in {'.', '..'} or ':' in part or part.endswith((' ', '.')) for part in parts)
|
|
50
|
+
or any(re.match(r'^(CON|PRN|AUX|NUL|COM[1-9]|LPT[1-9])(?:\.|$)', part, re.I) for part in parts)
|
|
51
|
+
or mode and stat.S_IFMT(mode) not in {0, stat.S_IFREG, stat.S_IFDIR}):
|
|
52
|
+
raise ValueError('The ZIP contains an unsupported path or file type.')
|
|
53
|
+
canonical = name.rstrip('/').casefold()
|
|
54
|
+
if canonical in seen:
|
|
55
|
+
raise ValueError('The ZIP contains conflicting paths.')
|
|
56
|
+
seen.add(canonical)
|
|
57
|
+
if not entries:
|
|
58
|
+
raise ValueError('The project ZIP is empty.')
|
|
59
|
+
root = Path(root)
|
|
60
|
+
root.mkdir(parents=True, exist_ok=True, mode=0o700)
|
|
61
|
+
destination = Path(tempfile.mkdtemp(prefix=project+'-', dir=root))
|
|
62
|
+
try:
|
|
63
|
+
for entry in entries:
|
|
64
|
+
path = destination.joinpath(*PurePosixPath(entry.filename).parts)
|
|
65
|
+
if entry.is_dir():
|
|
66
|
+
path.mkdir(parents=True, exist_ok=True)
|
|
67
|
+
continue
|
|
68
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
69
|
+
with archive.open(entry) as source, path.open('xb') as target:
|
|
70
|
+
shutil.copyfileobj(source, target)
|
|
71
|
+
os.chmod(path, 0o700 if (entry.external_attr >> 16) & 0o111 else 0o600)
|
|
72
|
+
except Exception:
|
|
73
|
+
shutil.rmtree(destination)
|
|
74
|
+
raise
|
|
75
|
+
return destination
|
|
76
|
+
except (zipfile.BadZipFile, RuntimeError, OSError):
|
|
77
|
+
raise ValueError('The project ZIP could not be prepared for Orca.') from None
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def open_package(encoded, root, project):
|
|
81
|
+
command = orca_command()
|
|
82
|
+
destination = unpack_package(encoded, root, project)
|
|
83
|
+
try:
|
|
84
|
+
subprocess.run([command, 'open', '--json'], check=True, capture_output=True, timeout=30)
|
|
85
|
+
subprocess.run([command, 'repo', 'add', '--path', str(destination), '--kind', 'folder', '--json'],
|
|
86
|
+
check=True, capture_output=True, timeout=30)
|
|
87
|
+
except (OSError, subprocess.SubprocessError):
|
|
88
|
+
# Keep the prepared folder for retry/manual opening. Do not expose command output.
|
|
89
|
+
return {'opened': False, 'path': str(destination), 'message': 'Orca did not open. Open this folder in Orca.'}
|
|
90
|
+
return {'opened': True, 'path': str(destination)}
|
package/server/pricing.py
CHANGED
|
@@ -61,6 +61,9 @@ def storage_quote(provider,region):
|
|
|
61
61
|
if provider=='aws':return {**aws_s3(region),'provider':'aws','available':True}
|
|
62
62
|
if provider=='azure':return dict(_azure_storage(region,int(time.time()//3600)))
|
|
63
63
|
if provider=='gcp':
|
|
64
|
+
if region=='us-central1':
|
|
65
|
+
try:return dict(_google_storage_public(region,int(time.time()//3600)))
|
|
66
|
+
except (ValueError,OSError):pass
|
|
64
67
|
import os
|
|
65
68
|
key=os.getenv('GOOGLE_CLOUD_BILLING_API_KEY')
|
|
66
69
|
if key and re.fullmatch(r'[a-z]+-[a-z]+\d',region):
|
|
@@ -80,5 +83,162 @@ def storage_quote(provider,region):
|
|
|
80
83
|
price=rates[0]['unitPrice'];value=float(price.get('units',0))+float(price.get('nanos',0))/1e9
|
|
81
84
|
candidates.append({'available':True,'provider':'gcp','price':value,'currency':'USD','unit':'GiB-month','region':region,'service':'Google Cloud Storage Standard / '+sku['skuId'],'source':'Google Cloud Billing Catalog','url':'https://cloud.google.com/skus/?currency=USD','date':datetime.now(timezone.utc).date().isoformat(),'coverage':'Standard storage only; operations, replication and transfer excluded'})
|
|
82
85
|
if len(candidates)==1:return candidates[0]
|
|
83
|
-
return {'available':False,'provider':'gcp','region':region,'sourceUrl':PROVIDER_PAGES[provider],'reason':'
|
|
86
|
+
return {'available':False,'provider':'gcp','region':region,'sourceUrl':PROVIDER_PAGES[provider],'reason':f'Storage pricing for {region or "the selected region"} could not be verified. Recalculate or enter a sourced rate.','nextQuestion':'Which Google Cloud region and storage class should we price?'}
|
|
84
87
|
return {'available':False,'provider':'ibm','region':region,'sourceUrl':PROVIDER_PAGES[provider],'reason':'IBM pricing depends on region, storage class and plan. No verified regional quote is configured; no AWS or invented rate is substituted.','nextQuestion':'Which IBM region and storage class or existing IBM quote should we use?'}
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def parse_lambda(data,region,url,architecture='x86_64'):
|
|
91
|
+
"""Regional on-demand base meters; never apply account-specific free tiers."""
|
|
92
|
+
suffix='-ARM' if architecture=='arm64' else ''
|
|
93
|
+
expected={'requests':('AWS-Lambda-Requests'+suffix,'Request'),
|
|
94
|
+
'compute':('AWS-Lambda-Duration'+suffix,'Lambda-GB-Second')}
|
|
95
|
+
meters={}
|
|
96
|
+
for name,(group,unit)in expected.items():
|
|
97
|
+
matches=[]
|
|
98
|
+
for sku,product in data.get('products',{}).items():
|
|
99
|
+
attrs=product.get('attributes',{})
|
|
100
|
+
if attrs.get('regionCode')!=region or attrs.get('group')!=group:continue
|
|
101
|
+
for term in data.get('terms',{}).get('OnDemand',{}).get(sku,{}).values():
|
|
102
|
+
for rate in term.get('priceDimensions',{}).values():
|
|
103
|
+
if rate.get('unit')!=unit or rate.get('beginRange')!='0':continue
|
|
104
|
+
value=float(rate.get('pricePerUnit',{}).get('USD','nan'))
|
|
105
|
+
if not 0<value<1:continue
|
|
106
|
+
end=rate.get('endRange')
|
|
107
|
+
matches.append({'price':value,'unit':unit,'service':'AWS Lambda / '+sku,
|
|
108
|
+
'tierLimit':None if end=='Inf'else float(end)})
|
|
109
|
+
if len(matches)!=1:raise ValueError('Could not uniquely verify the regional Lambda meter')
|
|
110
|
+
meters[name]=matches[0]
|
|
111
|
+
return {'available':True,'provider':'aws','region':region,'architecture':architecture,
|
|
112
|
+
'currency':'USD','source':'AWS public Price List','url':url,
|
|
113
|
+
'date':datetime.now(timezone.utc).date().isoformat(),'publicationDate':data.get('publicationDate'),
|
|
114
|
+
'meters':meters,'coverage':'On-demand requests and base compute only. Free tiers, logs, transfer, public gateway charges, provisioned concurrency, taxes and discounts are not included.'}
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
@lru_cache(maxsize=32)
|
|
118
|
+
def _lambda(region,architecture,hour):
|
|
119
|
+
url=f'https://pricing.us-east-1.amazonaws.com/offers/v1.0/aws/AWSLambda/current/{region}/index.json'
|
|
120
|
+
with urllib.request.urlopen(urllib.request.Request(url,headers={'User-Agent':'AtMem AI Architect/0.2'}),timeout=12)as response:raw=response.read(12_000_001)
|
|
121
|
+
if len(raw)>12_000_000:raise ValueError('Price list exceeds the supported size')
|
|
122
|
+
return parse_lambda(json.loads(raw),region,url,architecture)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def lambda_quote(region,architecture='x86_64'):
|
|
126
|
+
if not re.fullmatch(r'[a-z]{2}-(?:gov-)?[a-z]+-\d',region)or architecture not in {'x86_64','arm64'}:
|
|
127
|
+
raise ValueError('Select an explicit AWS region and Lambda architecture')
|
|
128
|
+
return dict(_lambda(region,architecture,int(time.time()//3600)))
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _public_rows(html):
|
|
132
|
+
from html.parser import HTMLParser
|
|
133
|
+
class Rows(HTMLParser):
|
|
134
|
+
def __init__(self):
|
|
135
|
+
super().__init__();self.rows=[];self.row=None;self.cell=None
|
|
136
|
+
def handle_starttag(self,tag,attrs):
|
|
137
|
+
if tag=='tr':self.row=[]
|
|
138
|
+
if tag in {'td','th'} and self.row is not None:self.cell=[]
|
|
139
|
+
def handle_data(self,data):
|
|
140
|
+
if self.cell is not None:self.cell.append(data)
|
|
141
|
+
def handle_endtag(self,tag):
|
|
142
|
+
if tag in {'td','th'} and self.cell is not None:
|
|
143
|
+
self.row.append(' '.join(' '.join(self.cell).split()));self.cell=None
|
|
144
|
+
if tag=='tr' and self.row is not None:self.rows.append(self.row);self.row=None
|
|
145
|
+
parser=Rows();parser.feed(html);return parser.rows
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def parse_google_public(html,service,url):
|
|
149
|
+
import hashlib
|
|
150
|
+
if service=='cloud-run':
|
|
151
|
+
marker='Services (Requests-based billing)'
|
|
152
|
+
if marker not in html:raise ValueError('Cloud Run request-based pricing section is missing')
|
|
153
|
+
html=html[html.index(marker):]
|
|
154
|
+
expected={'cpu':'CPU (per vCPU-second)','memory':'Memory (per GiB-second)','requests':'Requests (per 1,000,000)'}
|
|
155
|
+
units={'cpu':'vCPU-seconds','memory':'GiB-seconds','requests':'million requests'}
|
|
156
|
+
elif service=='cloud-sql':
|
|
157
|
+
if 'Iowa (us-central1)' not in html or 'Enterprise Edition - N4' not in html:
|
|
158
|
+
raise ValueError('Cloud SQL reference region or edition is missing')
|
|
159
|
+
marker='Enterprise Edition - N4';html=html[html.index(marker):]
|
|
160
|
+
expected={'cpu':'vCPUs','memory':'Memory'};units={'cpu':'vCPU-hours','memory':'GiB-hours'}
|
|
161
|
+
else:raise ValueError('Unsupported Google pricing service')
|
|
162
|
+
meters={}
|
|
163
|
+
for row in _public_rows(html):
|
|
164
|
+
if not row:continue
|
|
165
|
+
for key,label in expected.items():
|
|
166
|
+
if key in meters or row[0]!=label:continue
|
|
167
|
+
value=next((re.search(r'\$([0-9]+(?:\.[0-9]+)?)',cell)for cell in row[1:]if '$' in cell),None)
|
|
168
|
+
if value:
|
|
169
|
+
price=float(value.group(1))
|
|
170
|
+
if not 0<price<10:raise ValueError('Unexpected Google public price')
|
|
171
|
+
meters[key]={'price':price,'unit':units[key]}
|
|
172
|
+
if len(meters)==len(expected):break
|
|
173
|
+
if len(meters)!=len(expected):raise ValueError('Google public pricing meters could not be verified')
|
|
174
|
+
return {'available':True,'provider':'gcp','service':service,'region':'us-central1','currency':'USD',
|
|
175
|
+
'source':'Google Cloud public pricing','url':url,'date':datetime.now(timezone.utc).date().isoformat(),
|
|
176
|
+
'sourceSha256':hashlib.sha256(html.encode()).hexdigest(),'meters':meters,
|
|
177
|
+
'coverage':'US reference on-demand rates. Excludes free tiers, commitments, taxes, networking, backups, storage, external APIs and payment fees.'}
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
@lru_cache(maxsize=8)
|
|
181
|
+
def _google_public(service,hour):
|
|
182
|
+
path={'cloud-run':'run','cloud-sql':'sql'}.get(service)
|
|
183
|
+
if not path:raise ValueError('Unsupported Google pricing service')
|
|
184
|
+
url=f'https://cloud.google.com/{path}/pricing'
|
|
185
|
+
with urllib.request.urlopen(urllib.request.Request(url,headers={'User-Agent':'AtMem Architect pricing'}),timeout=15)as response:raw=response.read(8_000_001)
|
|
186
|
+
if len(raw)>8_000_000:raise ValueError('Google price page exceeds its size bound')
|
|
187
|
+
return parse_google_public(raw.decode('utf-8'),service,url)
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def google_public_quote(service,region,planning_reference=False):
|
|
191
|
+
if region!='us-central1' and not (planning_reference and (not region or re.search('select|unknown|tbd',region,re.I))):
|
|
192
|
+
raise ValueError('This public quote supports Iowa only; the selected region is preserved.')
|
|
193
|
+
return {**_google_public(service,int(time.time()//3600)),'planningReference':region!='us-central1'}
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def parse_google_storage_public(html, region, url):
|
|
197
|
+
"""Read an explicitly selected single-region public table, never a region list match."""
|
|
198
|
+
import hashlib
|
|
199
|
+
from html.parser import HTMLParser
|
|
200
|
+
if region != 'us-central1':
|
|
201
|
+
raise ValueError('The public default storage table verifies Iowa only.')
|
|
202
|
+
marker = 'id="data-storage"'
|
|
203
|
+
if marker not in html:
|
|
204
|
+
raise ValueError('Google data storage section is missing.')
|
|
205
|
+
section = html[html.index(marker):]
|
|
206
|
+
# Stop before dual-region rates. Region options alone do not verify selected pricing.
|
|
207
|
+
boundary = re.search(r'<(?:p|h[1-6])[^>]*>\s*(?:<b>)?Dual-region(?:</b>)?\s*</', section)
|
|
208
|
+
if not boundary:
|
|
209
|
+
raise ValueError('The single-region storage boundary is missing.')
|
|
210
|
+
section = section[:boundary.start()]
|
|
211
|
+
class Selected(HTMLParser):
|
|
212
|
+
def __init__(self):super().__init__();self.regions=[]
|
|
213
|
+
def handle_starttag(self,tag,attrs):
|
|
214
|
+
a=dict(attrs)
|
|
215
|
+
if a.get('role')=='option' and a.get('aria-selected')=='true':self.regions.append(a.get('data-value'))
|
|
216
|
+
selected=Selected();selected.feed(section)
|
|
217
|
+
if selected.regions != ['Iowa (us-central1)']:
|
|
218
|
+
raise ValueError('The selected public storage region is not Iowa.')
|
|
219
|
+
table = re.search(r'<table\b[^>]*>.*?</table>', section, re.S)
|
|
220
|
+
rows = _public_rows(table.group(0)) if table else []
|
|
221
|
+
if len(rows)!=2 or rows[0].count('Standard storage')!=1:
|
|
222
|
+
raise ValueError('The Standard storage table could not be verified.')
|
|
223
|
+
index=rows[0].index('Standard storage')
|
|
224
|
+
if index>=len(rows[1]):raise ValueError('The storage price is missing.')
|
|
225
|
+
cell=rows[1][index]
|
|
226
|
+
rate=re.fullmatch(r'\$([0-9]+(?:\.[0-9]+)?) / 1 gibibyte hour(?:, per 1 month / account)?',cell)
|
|
227
|
+
if not rate or not 0<float(rate.group(1))<1:raise ValueError('Unexpected Google storage rate or unit.')
|
|
228
|
+
hourly=float(rate.group(1))
|
|
229
|
+
return {'available':True,'provider':'gcp','price':hourly*730,'currency':'USD',
|
|
230
|
+
'unit':'GiB-month','region':region,'service':'Cloud Storage Standard / single-region',
|
|
231
|
+
'source':'Google Cloud public pricing','url':url,
|
|
232
|
+
'date':datetime.now(timezone.utc).date().isoformat(),
|
|
233
|
+
'sourceSha256':hashlib.sha256(section.encode()).hexdigest(),
|
|
234
|
+
'hourlyPrice':hourly,'hoursPerMonth':730,
|
|
235
|
+
'coverage':'Single-region Standard storage; 730-hour planning month. Operations, replication, transfer, taxes and discounts excluded.'}
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
@lru_cache(maxsize=32)
|
|
239
|
+
def _google_storage_public(region,hour):
|
|
240
|
+
url='https://cloud.google.com/storage/pricing'
|
|
241
|
+
with urllib.request.urlopen(urllib.request.Request(url,headers={'User-Agent':'AtMem Architect pricing'}),timeout=12) as response:
|
|
242
|
+
raw=response.read(8_000_001)
|
|
243
|
+
if len(raw)>8_000_000:raise ValueError('Google storage page exceeds its size bound.')
|
|
244
|
+
return parse_google_storage_public(raw.decode('utf-8'),region,url)
|
package/server/prompts.py
CHANGED
|
@@ -121,3 +121,16 @@ new project; preserve an existing client's chosen name. The host constructs a co
|
|
|
121
121
|
proposal after validation. Do not spend output tokens copying the entire current graph,
|
|
122
122
|
original brief or unchanged configuration just to propose a small correction.
|
|
123
123
|
"""
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
SYSTEM_PROMPT += """
|
|
127
|
+
# Owner UX instruction: avoid word vomit
|
|
128
|
+
Keep the visible chat reply quick and concise: normally one or two short sentences,
|
|
129
|
+
at most 20 words total. Longer optional explanation belongs behind a help disclosure or in the specification. Ask one direct question when a decision is needed.
|
|
130
|
+
Do not recap the brief, repeat accepted choices, narrate your process, advertise a
|
|
131
|
+
skill or add examples unless asked. Example: 'Use USD for welcome credit and top-ups?'
|
|
132
|
+
Preserve full requirements, rationale, uncertainty and technical detail in structured
|
|
133
|
+
project/specification fields. Do not shorten or discard source requirements.
|
|
134
|
+
If a material blocker needs explaining, state its cause and next action briefly.
|
|
135
|
+
User requests for detailed specifications still receive complete specifications.
|
|
136
|
+
"""
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
"""Bounded, temporary, ordered request assembly. Uploaded content is never executed."""
|
|
2
|
+
import hashlib
|
|
3
|
+
import secrets
|
|
4
|
+
import tempfile
|
|
5
|
+
import threading
|
|
6
|
+
import time
|
|
7
|
+
|
|
8
|
+
MAX_BYTES = 64 * 1024 * 1024
|
|
9
|
+
CHUNK_BYTES = 128 * 1024
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class RequestUploads:
|
|
13
|
+
def __init__(self):
|
|
14
|
+
self.rows = {}
|
|
15
|
+
self.lock = threading.Lock()
|
|
16
|
+
|
|
17
|
+
def _expire(self):
|
|
18
|
+
for key, row in list(self.rows.items()):
|
|
19
|
+
if time.monotonic() - row['updated'] > 1800:
|
|
20
|
+
self.rows.pop(key)['file'].close()
|
|
21
|
+
|
|
22
|
+
def start(self, size, digest):
|
|
23
|
+
with self.lock:
|
|
24
|
+
self._expire()
|
|
25
|
+
if not 0 < size <= MAX_BYTES:
|
|
26
|
+
raise ValueError('Request exceeds the 64 MiB upload limit.')
|
|
27
|
+
if len(self.rows) >= 8 or sum(r['size'] for r in self.rows.values()) + size > 2 * MAX_BYTES:
|
|
28
|
+
raise ValueError('Upload capacity is busy. Retry after current uploads finish.')
|
|
29
|
+
key = secrets.token_hex(24)
|
|
30
|
+
self.rows[key] = dict(file=tempfile.TemporaryFile(), size=size, digest=digest,
|
|
31
|
+
received=0, index=0, updated=time.monotonic())
|
|
32
|
+
return {'uploadId': key, 'chunkBytes': CHUNK_BYTES}
|
|
33
|
+
|
|
34
|
+
def append(self, key, index, content):
|
|
35
|
+
with self.lock:
|
|
36
|
+
self._expire()
|
|
37
|
+
row = self.rows.get(key)
|
|
38
|
+
if row is None:
|
|
39
|
+
raise ValueError('Upload expired. Start validation again.')
|
|
40
|
+
if index != row['index'] or not 0 < len(content) <= CHUNK_BYTES or row['received'] + len(content) > row['size']:
|
|
41
|
+
raise ValueError('Upload chunk is out of order or exceeds its declared size.')
|
|
42
|
+
row['file'].write(content)
|
|
43
|
+
row.update(received=row['received'] + len(content), index=index + 1, updated=time.monotonic())
|
|
44
|
+
return {'receivedBytes': row['received']}
|
|
45
|
+
|
|
46
|
+
def take(self, key):
|
|
47
|
+
with self.lock:
|
|
48
|
+
self._expire()
|
|
49
|
+
row = self.rows.pop(key, None)
|
|
50
|
+
if row is None:
|
|
51
|
+
raise ValueError('Upload expired. Start validation again.')
|
|
52
|
+
try:
|
|
53
|
+
row['file'].seek(0)
|
|
54
|
+
data = row['file'].read()
|
|
55
|
+
if len(data) != row['size'] or hashlib.sha256(data).hexdigest() != row['digest']:
|
|
56
|
+
raise ValueError('Upload is incomplete or its checksum changed. Retry validation.')
|
|
57
|
+
return data
|
|
58
|
+
finally:
|
|
59
|
+
row['file'].close()
|
|
60
|
+
|
|
61
|
+
def cancel(self, key):
|
|
62
|
+
with self.lock:
|
|
63
|
+
row = self.rows.pop(key, None)
|
|
64
|
+
if row:
|
|
65
|
+
row['file'].close()
|
|
@@ -11,13 +11,49 @@ from pathlib import Path
|
|
|
11
11
|
import shutil
|
|
12
12
|
import subprocess
|
|
13
13
|
import tempfile
|
|
14
|
+
import base64
|
|
15
|
+
import binascii
|
|
14
16
|
from fastapi import APIRouter, HTTPException
|
|
15
|
-
from pydantic import BaseModel, ConfigDict
|
|
17
|
+
from pydantic import BaseModel, ConfigDict, Field, ValidationError
|
|
18
|
+
from .request_uploads import RequestUploads, MAX_BYTES
|
|
16
19
|
from typing import Literal
|
|
17
20
|
|
|
18
21
|
router = APIRouter()
|
|
19
22
|
LOCK = asyncio.Lock()
|
|
20
23
|
ROOT = Path(__file__).resolve().parent.parent
|
|
24
|
+
UPLOADS = RequestUploads()
|
|
25
|
+
|
|
26
|
+
class UploadStart(BaseModel):
|
|
27
|
+
model_config = ConfigDict(extra='forbid')
|
|
28
|
+
bytes: int = Field(gt=0, le=MAX_BYTES, strict=True)
|
|
29
|
+
sha256: str = Field(pattern=r'^[a-f0-9]{64}$')
|
|
30
|
+
|
|
31
|
+
class UploadId(BaseModel):
|
|
32
|
+
model_config = ConfigDict(extra='forbid')
|
|
33
|
+
uploadId: str = Field(pattern=r'^[a-f0-9]{48}$')
|
|
34
|
+
|
|
35
|
+
class UploadChunk(UploadId):
|
|
36
|
+
index: int = Field(ge=0, strict=True)
|
|
37
|
+
data: str = Field(min_length=1, max_length=174764)
|
|
38
|
+
|
|
39
|
+
@router.post('/api/terraform/uploads/start')
|
|
40
|
+
async def upload_start(payload: UploadStart):
|
|
41
|
+
try:
|
|
42
|
+
return UPLOADS.start(payload.bytes, payload.sha256)
|
|
43
|
+
except ValueError as error:
|
|
44
|
+
raise HTTPException(409, str(error))
|
|
45
|
+
|
|
46
|
+
@router.post('/api/terraform/uploads/chunk')
|
|
47
|
+
async def upload_chunk(payload: UploadChunk):
|
|
48
|
+
try:
|
|
49
|
+
return UPLOADS.append(payload.uploadId, payload.index, base64.b64decode(payload.data, validate=True))
|
|
50
|
+
except (ValueError, binascii.Error) as error:
|
|
51
|
+
raise HTTPException(422, str(error))
|
|
52
|
+
|
|
53
|
+
@router.post('/api/terraform/uploads/cancel')
|
|
54
|
+
async def upload_cancel(payload: UploadId):
|
|
55
|
+
UPLOADS.cancel(payload.uploadId)
|
|
56
|
+
return {'cancelled': True}
|
|
21
57
|
|
|
22
58
|
class ValidationRequest(BaseModel):
|
|
23
59
|
model_config = ConfigDict(extra='forbid')
|
|
@@ -116,3 +152,13 @@ async def validate_route(payload: ValidationRequest):
|
|
|
116
152
|
raise HTTPException(504, 'Terraform validation timed out. No deployment was attempted.')
|
|
117
153
|
except (ValueError, OSError):
|
|
118
154
|
raise HTTPException(422, 'Could not validate the generated Terraform draft. Check the project and local Terraform installation.')
|
|
155
|
+
|
|
156
|
+
@router.post('/api/terraform/validate-upload')
|
|
157
|
+
async def validate_upload(payload: UploadId):
|
|
158
|
+
if LOCK.locked():
|
|
159
|
+
raise HTTPException(409, 'A Terraform validation is already running.')
|
|
160
|
+
try:
|
|
161
|
+
request = ValidationRequest.model_validate_json(UPLOADS.take(payload.uploadId))
|
|
162
|
+
except (ValueError, ValidationError):
|
|
163
|
+
raise HTTPException(422, 'The upload is incomplete, changed, or is not a valid project validation request.')
|
|
164
|
+
return await validate_route(request)
|