@atmem/architect 0.2.0-alpha.4.1 → 0.2.0

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.
Files changed (55) hide show
  1. package/LICENSE +202 -0
  2. package/NOTICE +6 -0
  3. package/README.md +19 -21
  4. package/dist/assets/index-BTLHaa3J.css +1 -0
  5. package/dist/assets/{index-qtCM2D7O.js → index-CQ2C1gKF.js} +19206 -19139
  6. package/dist/assets/{index.es-C6C0sneB.js → index.es-DTyqIdaO.js} +1 -1
  7. package/dist/assets/{visual-export-DjbjgQbA.js → visual-export-Ox0j5xj5.js} +3 -3
  8. package/dist/index.html +2 -2
  9. package/dist/licenses/atmem-APACHE-2.0.txt +202 -0
  10. package/package.json +2 -2
  11. package/public/licenses/atmem-APACHE-2.0.txt +202 -0
  12. package/server/app.py +1 -1
  13. package/server/codex_app_server.py +5 -5
  14. package/server/codex_worker_policy.py +27 -4
  15. package/server/engineering_artifacts.py +7 -1
  16. package/server/engineering_handoff.py +9 -0
  17. package/server/engineering_routes.py +16 -2
  18. package/server/local_providers.py +7 -3
  19. package/server/memory_context.py +58 -0
  20. package/server/memory_routes.py +67 -8
  21. package/server/memory_service.py +37 -4
  22. package/server/memory_worker.py +72 -9
  23. package/server/orca_handoff.py +90 -0
  24. package/server/pricing.py +106 -0
  25. package/server/prompts.py +13 -0
  26. package/server/windows_snapshot.py +119 -0
  27. package/src/ArchitectureWorkPanel.jsx +0 -7
  28. package/src/ArtifactChecks.jsx +2 -2
  29. package/src/ChatMessage.jsx +12 -2
  30. package/src/EngagementViews.jsx +18 -16
  31. package/src/EngineeringPanel.jsx +27 -27
  32. package/src/FileTree.jsx +7 -0
  33. package/src/FinalChecklist.jsx +15 -0
  34. package/src/HelpNote.jsx +3 -0
  35. package/src/ImplementationDelivery.jsx +52 -16
  36. package/src/JourneyViews.jsx +19 -16
  37. package/src/LocationChoices.jsx +12 -0
  38. package/src/MemoryPanel.jsx +43 -31
  39. package/src/OpenAIVoicePanel.jsx +15 -14
  40. package/src/OutputsView.jsx +21 -15
  41. package/src/ProjectChecks.jsx +44 -0
  42. package/src/WorkbenchDialogs.jsx +1 -1
  43. package/src/conversation.mjs +1 -0
  44. package/src/delivery.mjs +1 -1
  45. package/src/estimate.mjs +5 -3
  46. package/src/final-package.mjs +34 -0
  47. package/src/journey-workspace.css +34 -0
  48. package/src/journey.mjs +12 -2
  49. package/src/main.jsx +119 -96
  50. package/src/output-bundle.mjs +4 -2
  51. package/src/pricing.mjs +56 -2
  52. package/src/useChatScroll.jsx +5 -4
  53. package/src/useEngineeringStatus.mjs +17 -0
  54. package/src/validation-receipt.mjs +1 -1
  55. package/dist/assets/index-B97MkVMs.css +0 -1
@@ -1,5 +1,6 @@
1
1
  // Review, Estimate and Deploy steps of the AtMem AI Architect journey.
2
2
  import React from "react";
3
+ import {Check} from "lucide-react";
3
4
  import { ATTRIBUTES, acceptRisk, blockers } from "./review.mjs";
4
5
  import { DISCLAIMER, INPUTS, PROJECT_SIZES, setProjectSize, setPrice, setUsage } from "./estimate.mjs";
5
6
  import { deploymentSteps } from "./document.mjs";
@@ -30,7 +31,8 @@ function Accept({ onAccept }) {
30
31
  );
31
32
  }
32
33
 
33
- export function ReviewView({ project, findings, onChange, onOpen, onAddRequirement }) {
34
+ export function ReviewView({ project, findings, onChange, onOpen, onAddRequirement,expanded=false }) {
35
+ const ChoicesContainer=expanded?'section':'details',ChoicesHeading=expanded?'h3':'summary';
34
36
  const names = Object.fromEntries(project.nodes.map((n) => [n.id, n.data.label]));
35
37
  const block = blockers(findings);
36
38
  const open = findings.filter((f) => f.status === "open");
@@ -46,7 +48,7 @@ export function ReviewView({ project, findings, onChange, onOpen, onAddRequireme
46
48
  : "Nothing open. Rules run on every change."}
47
49
  </p>
48
50
  </header>
49
- <details className="extra"><summary>Agreed choices and consequences</summary>{(project.decisions||[]).filter(d=>d.status==='Accepted').map(d=><article key={d.id||d.subject}><strong>{d.title||d.subject}</strong><p>{d.decision}</p>{d.context&&<p>{d.context}</p>}{d.consequences&&<p>{d.consequences}</p>}</article>)}</details>
51
+ <ChoicesContainer className="extra"><ChoicesHeading>Agreed choices and consequences</ChoicesHeading>{(project.decisions||[]).filter(d=>d.status==='Accepted').map(d=><article key={d.id||d.subject}><strong>{d.title||d.subject}</strong><p>{d.decision}</p>{d.context&&<p>{d.context}</p>}{d.consequences&&<p>{d.consequences}</p>}</article>)}</ChoicesContainer>
50
52
  {ATTRIBUTES.map(([id, label]) => {
51
53
  const fs = findings.filter((f) => f.attribute === id);
52
54
  if (!fs.length) return null;
@@ -90,24 +92,24 @@ export function ReviewView({ project, findings, onChange, onOpen, onAddRequireme
90
92
  );
91
93
  }
92
94
 
93
- export function EstimateView({ project, result, onChange, onEstimate, busy=false }) {
95
+ export function EstimateView({ project, result, onChange, onEstimate, busy=false, pricing, onNext }) {
94
96
  const e = result;
95
97
  return (
96
98
  <section className="doc" aria-label="Estimate">
97
99
  <header className="doc-head">
98
- <h2>Costs</h2>
99
- <p className="muted">Choose an expected project size. We use it to estimate usage; you can refine the assumptions later. {DISCLAIMER}</p>
100
+ <div className="cost-heading"><h2>1. Cost estimate</h2>{onEstimate&&<button type="button" className="btn" disabled={!!pricing?.busy} onClick={()=>onEstimate(project)}>{pricing?.busy?'Calculating…':'Recalculate'}</button>}</div>
101
+ <p className="muted">Monthly planning estimate. {DISCLAIMER}</p>
100
102
  </header>
101
- <fieldset disabled={busy}><legend>How large do you expect this project to be?</legend>
102
- <div className="row">{PROJECT_SIZES.map(size=><button type="button" className="btn" key={size.id} aria-pressed={project.estimate?.projectSize===size.id} onClick={()=>{const next=setProjectSize(project,size.id);onChange(next);onEstimate?.(next);}}>{size.label}</button>)}</div>
103
- <p className="muted small">Small: 10,000 requests / 10 GB; Medium: 100,000 / 100 GB; Large: 1 million / 1 TB; Extra Large: 10 million / 10 TB per month. Existing usage figures take priority. Model assumptions: 3,000 tokens per request and 730 endpoint hours; training usage needs separate agreement.</p>
103
+ <fieldset className="cost-size-selector"><legend>How large do you expect this project to be?</legend>
104
+ <div className="row">{PROJECT_SIZES.map(size=><button type="button" className={project.estimate?.projectSize===size.id?"btn primary":"btn"} key={size.id} aria-pressed={project.estimate?.projectSize===size.id} onClick={()=>{if(project.estimate?.projectSize===size.id)return;const next=setProjectSize(project,size.id);onChange(next);onEstimate?.(next);}}>{project.estimate?.projectSize===size.id&&<Check size={16} aria-hidden="true"/>}{size.label}</button>)}</div>
105
+ <details className="cost-assumptions"><summary>Planning assumptions</summary><p className="muted small">Small: 10,000 requests / 10 GB; Medium: 100,000 / 100 GB; Large: 1 million / 1 TB; Extra Large: 10 million / 10 TB per month. Existing usage figures take priority. Model assumptions: 3,000 tokens per request and 730 endpoint hours; training usage needs separate agreement.</p></details>
104
106
  </fieldset>
105
- {busy&&<p role="status">Architect is preparing the estimate…</p>}
106
- {onEstimate&&project.estimate?.projectSize&&<button className="link" disabled={busy} onClick={()=>onEstimate(project)}>Refresh estimate with Architect</button>}
107
+ {project.estimate?.projectSize&&<p className="cost-selection-status" role="status">{PROJECT_SIZES.find(size=>size.id===project.estimate.projectSize)?.label} · {pricing?.busy?'Updating prices…':e.complete?'Estimate ready':e.lines.some(line=>line.cost!=null)?'Partial estimate':'Prices unavailable'}</p>}{pricing?.errors?.map((error,i)=><p className="warn-text" key={i}>{error.component&&`${error.component}: `}{error.reason} <button className="link" disabled={busy||pricing?.busy} onClick={()=>onEstimate?.(project)}>Retry pricing</button></p>)}
108
+
107
109
  <div className="totals">
108
110
  <div>
109
111
  <small className="muted">Estimated subtotal / month</small>
110
- <strong>{e.lines.some((l) => l.cost != null) ? money(e.expected, e.currency) : "—"}</strong>
112
+ <strong>{e.lines.some((l) => l.cost != null) ? e.expected===0&&e.preciseExpected>0?`< ${money(0.01,e.currency)}`:money(e.expected, e.currency) : "—"}</strong>
111
113
  </div>
112
114
  <div>
113
115
  <small className="muted">Range</small>
@@ -118,7 +120,7 @@ export function EstimateView({ project, result, onChange, onEstimate, busy=false
118
120
  <div>
119
121
  <small className="muted">Status</small>
120
122
  <span className={e.complete ? "ok-text" : "warn-text"}>
121
- {e.complete ? "All entered charges estimated" : e.mixedCurrencies ? "Currency needs review" : "Some charges not yet estimated"}
123
+ {e.complete ? "All entered charges estimated" : e.mixedCurrencies ? "Currency needs review" : "Partial estimate · see exclusions"}
122
124
  </span>
123
125
  </div>
124
126
  {e.budget && (
@@ -131,14 +133,15 @@ export function EstimateView({ project, result, onChange, onEstimate, busy=false
131
133
  </div>
132
134
  )}
133
135
  </div>
134
- <p className="muted small">The subtotal includes priced charges only. Architect needs to resolve unpriced services before this becomes a full estimate.</p>
136
+ <p className="muted small">{e.lines.some(line=>line.price?.planningReference)?'US reference rates · cloud region undecided. Unpriced charges excluded.':'Unpriced charges are excluded.'}</p>
135
137
  {e.drivers.length > 0 && (
136
138
  <p className="small">
137
139
  Biggest drivers: {e.drivers.map((d) => `${d.component} (${money(d.cost, d.price.currency)})`).join(", ")}
138
140
  </p>
139
141
  )}
140
- {e.lines.some(l=>l.cost==null)&&<p role="status">{e.lines.filter(l=>l.cost==null).length} charges await {e.lines.some(l=>l.quantity==null)?'usage assumptions or service pricing':'service pricing'}. {onEstimate?'Ask Architect using the estimate action above; detailed inputs are optional below.':'Service prices must be connected before these charges can be calculated.'}</p>}
141
- <details className="extra"><summary>Cost breakdown and price sources</summary><div className="resource-schedule" aria-label="Estimate breakdown">{e.lines.map(l=><article key={l.id}><strong>{l.component} · {l.driver}</strong><span>{l.cost==null?'Not yet estimated':money(l.cost,l.price.currency)+'/month'}</span><small>{l.quantity??'Usage unresolved'} {l.unit}{l.price&&<> · {l.price.source}{l.price.url&&<> · <a href={l.price.url} target="_blank" rel="noreferrer">price source</a></>}</>}</small></article>)}</div></details>
142
+ {e.lines.some(l=>l.cost==null)&&<p role="status">{e.lines.filter(l=>l.cost==null).length} charges unpriced.</p>}
143
+ {onNext&&<button type="button" className="btn" onClick={onNext}>Project files ↓</button>}
144
+ <details className="extra"><summary>Cost breakdown and price sources</summary><div className="resource-schedule" aria-label="Estimate breakdown">{e.lines.map(l=><article key={l.id}><strong>{l.component} · {l.driver}</strong><span>{l.cost==null?'Not yet estimated':money(l.cost,l.price.currency)+'/month'}</span><small>{l.note||l.price?.note}</small><small>{l.quantity??'Usage unresolved'} {l.unit}{l.price&&<> · {l.price.source}{l.price.url&&<> · <a href={l.price.url} target="_blank" rel="noreferrer">price source</a></>}</>}</small></article>)}</div></details>
142
145
  <details className="extra"><summary>Adjust usage assumptions and prices</summary>
143
146
  {e.lines.some((l) => !l.detailed) && <>
144
147
  <div className="doc-sub">
@@ -394,7 +397,7 @@ export function ProjectsDialog({ list, currentId, onOpen, onNew, onBin, onRestor
394
397
  </ul>
395
398
  </details>
396
399
  )}
397
- <p className="modal-fine">Saved in {folder || "~/.atforma"} under your account on this computer.</p>
400
+ <p className="modal-fine">{folder || "~/.atforma"}</p>
398
401
  </div>
399
402
  );
400
403
  }
@@ -0,0 +1,12 @@
1
+ import React,{useState,useEffect}from'react';
2
+ import{newDecision}from'./engagement.mjs';import{reviseDecision}from'./records.mjs';
3
+ const latest=(project,subject)=>[...(project.decisions||[])].reverse().find(d=>d.subject===subject&&d.status==='Accepted');
4
+ export function LocationChoices({project,onChange,onDiscuss}){
5
+ const cloud=latest(project,'platform.cloud')?.decision,country=latest(project,'data.country')?.decision,current=latest(project,'cloud.region');
6
+ const [region,setRegion]=useState(current?.decision||''),[error,setError]=useState('');
7
+ useEffect(()=>setRegion(current?.decision||''),[project.id,current?.decision]);
8
+ function apply(event){event.preventDefault();const value=region.trim();if(!cloud||!value)return;if(!/^[a-z][a-z0-9-]{2,39}$/.test(value)){setError('Enter a region code.');return;}if(cloud==='gcp'&&/^(usa|us|united states)$/i.test(country||'')&&!value.startsWith('us-')){setError('Choose a US region for USA residency.');return;}
9
+ const fields={title:'Cloud region',decision:value,status:'Accepted',owner:'user',reason:'Selected in Choices.'};let next=current?reviseDecision(project,current.id,fields,fields.reason):{...project,decisions:[...(project.decisions||[]),newDecision(project,{...fields,subject:'cloud.region'})]};
10
+ next={...next,nodes:next.nodes.map(node=>{const previous=node.data.config?.region;return node.data.provider===cloud&&node.data.origin!=='existing'&&(!previous||/select|unknown|tbd/i.test(previous)||previous===current?.decision)?{...node,data:{...node.data,config:{...node.data.config,region:value}}}:node;})};onChange(next);setError('');}
11
+ return <section className="doc location-choices" aria-label="Country and region"><header className="doc-head"><h2>Country &amp; region</h2></header><p><strong>Data country:</strong> {country||'Not selected'}</p><form className="location-region-form" onSubmit={apply}><label>Cloud region<input aria-label="Cloud region" value={region} maxLength={40} placeholder={cloud==='gcp'?'e.g. us-central1':'Region code'} onChange={event=>setRegion(event.target.value)}/></label><button className="btn primary" disabled={!cloud||!region.trim()||region.trim()===current?.decision}>Apply</button></form>{current&&<p className="ok-text" role="status">✓ {current.decision}</p>}{error&&<p role="alert">{error}</p>}<button className="link" onClick={onDiscuss}>Discuss location</button></section>;
12
+ }
@@ -12,35 +12,47 @@ export function ContextUsed({ value }) {
12
12
  </details>;
13
13
  }
14
14
 
15
- export function MemoryPanel({ session, project, onError, onStatus }) {
16
- const [report,setReport]=useState(null),[error,setError]=useState(''),[tab,setTab]=useState('profile'),[field,setField]=useState('explanation'),[value,setValue]=useState(''),[working,setWorking]=useState(false),[refreshing,setRefreshing]=useState(true),[preview,setPreview]=useState(null);
17
- const payload={projectId:project.id};
18
- async function refresh() {
19
- setRefreshing(true);setError('');
20
- try {
21
- await memoryCall(session,'sync',{...payload,file:toProjectFile(project)});
22
- const inspected=await memoryCall(session,'inspect',payload);setReport(inspected);
23
- onStatus?.({enabled:inspected.enabled,status:inspected.status,pending:inspected.pending,rejected:inspected.rejected,engine:inspected.engine});
24
- } catch(e) { setError(e.message); } finally {setRefreshing(false);}
25
- }
26
- useEffect(()=>{refresh();},[project.id,session?.token]);
27
- async function act(fn) { setWorking(true);setError('');try{await fn();await refresh();}catch(e){setError(e.message);onError?.(e.message);}finally{setWorking(false);} }
28
- const records=report?.[tab] || [];
29
- return <div className="memory-panel">
30
- <h2>AtMem dashboard</h2>
31
- <p className="muted small">Durable preferences and confirmed decisions only. Drafts, canvas comments and routine edits stay in your project; they do not become long-term memory.</p>
32
- {(working||refreshing)&&<p role="status"><BrainActivity/> Updating memory…</p>}
33
- {error && <p className="error-box" role="alert">{error}</p>}
34
- {!report ? <button className="btn" onClick={refresh}>Retry memory connection</button> : <>
35
- <div className="row"><span className={report.status==='unavailable'?'warn-text':'muted'}>{report.enabled ? report.status==='unavailable'?'Memory unavailable — project only':`${report.engine} · connected` : 'Memory off for this project'}</span><span className="small">{report.pending} pending · {report.rejected} rejected</span><button className="btn ghost" disabled={working} onClick={()=>act(async()=>{await memoryCall(session,'settings',{...payload,enabled:!report.enabled});})}>{report.enabled?'Turn memory off':'Turn memory on'}</button></div>
36
- <p className="muted small">Policy: {report.policy}. Corrections supersede earlier values. Forget explicitly removes the selected memory and its history; project files remain separate.</p>
37
- <div className="seg" role="tablist" aria-label="Memory views">{[['profile','About you'],['project','This project'],['skills','Skills'],['activity','Activity']].map(([id,label])=><button key={id} role="tab" aria-selected={tab===id} className={tab===id?'on':''} onClick={()=>{setTab(id);setPreview(null);}}>{label}</button>)}</div>
38
- {tab==='profile' && <form className="memory-profile" onSubmit={e=>{e.preventDefault();act(async()=>{await memoryCall(session,'profile',{...payload,field,value});setValue('');});}}><label className="field">Preference<select aria-label="Preference type" value={field} onChange={e=>setField(e.target.value)}><option value="explanation">How to explain things</option><option value="experience">Experience and role</option><option value="iac">Infrastructure tool preference</option><option value="preferences">Other confirmed preferences</option></select></label><label className="field">What should AtMem AI Architect remember?<textarea aria-label="Remember about me" value={value} maxLength={1000} onChange={e=>setValue(e.target.value)} placeholder="For example: explain things concisely; I prefer Pulumi." /></label><button className="btn primary" disabled={working||!report.enabled||!value.trim()}>Remember preference</button></form>}
39
- {(tab==='profile'||tab==='project') && <><div className="resource-schedule">{records.map(r=><article key={r.id}><strong>{r.status==='active'?'Current':'Earlier value'} · {r.fact_key}</strong><span>{r.content}</span><small>{r.created_at?.slice(0,10)} · {r.id}</small>{r.status==='active' && <button className="btn ghost" disabled={working} onClick={()=>act(async()=>setPreview({...(await memoryCall(session,'forget-preview',{...payload,scope:tab==='profile'?'user':'project',recordId:r.id})),scope:tab==='profile'?'user':'project',recordId:r.id}))}>Preview forget</button>}</article>)}</div>{!records.length&&<p className="muted">No memories recorded in this view yet.</p>}</>}
40
- {preview && <section className="memory-preview"><h3>Review removal</h3><p>{preview.notice}</p><p>{preview.records.length} current/historical records will be removed.</p><button className="btn danger" disabled={working} onClick={()=>act(async()=>{await memoryCall(session,'forget',{...payload,scope:preview.scope,recordId:preview.recordId});setPreview(null);})}>Confirm forget</button><button className="btn ghost" onClick={()=>setPreview(null)}>Keep memory</button></section>}
41
- {tab==='skills' && <div className="resource-schedule">{report.skills.map(s=><article key={s.id}><strong>{s.name} v{s.version}</strong><small>{s.coverage} · integrity {s.integrity?'verified':'failed'}</small><span>{s.source.startsWith('https:')?<a href={s.source} target="_blank" rel="noreferrer">Pinned source</a>:s.source}</span><small>SHA256: {s.digest.slice(0,16)}… · tools: {s.tools.join(', ')}</small><button className="btn ghost" disabled={working||!s.integrity} onClick={()=>act(async()=>memoryCall(session,'skill',{...payload,skillId:s.id,enabled:!s.enabled}))}>{s.enabled?'Disable':'Enable'} {s.id}</button></article>)}<p className="muted small">Upstream files are pinned references with their licences. Missing companion files and cloud tool packages are not activated. AtMem AI Architect loads its reviewed specialist guidance; no bundled scripts run.</p></div>}
42
- {tab==='activity' && <div className="resource-schedule">{report.activity.map(a=><article key={a.id}><strong>{a.success?'Architect turn completed':'Turn did not complete'} · {a.createdAt.slice(0,19)}</strong><span>{a.recordIds.length} memory records · skills: {a.skillIds.join(', ')}</span><small>{a.boundary} · context digest {a.contextDigest.slice(0,16)}…</small>{a.tools.map((t,i)=><small key={i}>{t.name}: {t.success?'completed':'unavailable'}</small>)}</article>)}{!report.activity.length&&<p className="muted">No recorded architect turns for this project yet.</p>}</div>}
43
- <button className="btn ghost" disabled={working} onClick={()=>act(refresh)}>Refresh / retry pending sync</button>
44
- </>}
45
- </div>;
15
+ export function MemoryPanel({session,project,onError,onStatus}){
16
+ const [report,setReport]=useState(null),[tab,setTab]=useState('project'),[error,setError]=useState(''),[working,setWorking]=useState(false),[loading,setLoading]=useState(true);
17
+ const [query,setQuery]=useState(''),[search,setSearch]=useState(''),[status,setStatus]=useState('active'),[cursor,setCursor]=useState(null),[history,setHistory]=useState([]),[page,setPage]=useState(null),[revision,setRevision]=useState(0);
18
+ const [selected,setSelected]=useState(null),[preview,setPreview]=useState(null),[field,setField]=useState('preferences'),[value,setValue]=useState(''),[profileName,setProfileName]=useState('Default profile');
19
+ const scope=tab==='profile'?'user':'project',payload={projectId:project.id},date=value=>value?new Date(value).toLocaleString():'Unknown',short=value=>value?value.slice(0,8):'Unknown';
20
+ async function refresh(){
21
+ setWorking(true);setError('');try{
22
+ await memoryCall(session,'sync',{...payload,file:toProjectFile(project)});
23
+ const result=await memoryCall(session,'overview',payload);setReport(result);setProfileName(result.userProfile?.name||'Default profile');
24
+ onStatus?.({enabled:result.enabled,status:result.status,pending:result.pending,rejected:result.rejected,engine:result.engine});
25
+ setRevision(value=>value+1);
26
+ }catch(error){setError(error.message);}finally{setWorking(false);}
27
+ }
28
+ useEffect(()=>{void refresh();},[project.id,session?.token]);
29
+ useEffect(()=>{const timer=setTimeout(()=>{setSearch(query);setCursor(null);setHistory([]);},250);return()=>clearTimeout(timer);},[query]);
30
+ useEffect(()=>{
31
+ if(!['profile','project'].includes(tab))return;
32
+ const controller=new AbortController();setLoading(true);setError('');
33
+ memoryCall(session,'records',{...payload,scope,status,query:search,cursor,limit:50},controller.signal).then(value=>{if(!controller.signal.aborted)setPage(value);}).catch(error=>{if(!controller.signal.aborted)setError(error.message);}).finally(()=>{if(!controller.signal.aborted)setLoading(false);});
34
+ return()=>controller.abort();
35
+ },[project.id,session?.token,scope,tab,status,search,cursor,revision]);
36
+ async function act(operation){setWorking(true);setError('');try{await operation();setCursor(null);setHistory([]);await refresh();}catch(error){setError(error.message);onError?.(error.message);}finally{setWorking(false);}}
37
+ async function openRecord(record){setSelected({record,audit:[]});try{setSelected(await memoryCall(session,'record',{...payload,scope,recordId:record.id}));}catch(error){setError(error.message);}}
38
+ function chooseTab(value){setTab(value);setSelected(null);setPreview(null);setCursor(null);setHistory([]);setQuery('');setSearch('');}
39
+ return <div className="memory-panel memory-browser">
40
+ <header className="memory-heading"><h2>AtMem memory</h2><button className="btn" disabled={working} onClick={refresh}>Refresh</button></header>
41
+ <div className="memory-profile-bar"><label>Profile <input aria-label="Profile name" value={profileName} maxLength={80} onChange={event=>setProfileName(event.target.value)}/></label><button className="btn" disabled={working||!profileName.trim()||profileName===report?.userProfile?.name} onClick={()=>act(async()=>{await memoryCall(session,'user-profile',{name:profileName});})}>Save</button><code title="Local profile user ID">{report?.userProfile?.id||'Loading profile…'}</code><span className="memory-state">{report?.enabled?'On':'Off'}{report?.status==='unavailable'?' · Unavailable':''}</span><button className="link" disabled={working||!report} onClick={()=>act(()=>memoryCall(session,'settings',{...payload,enabled:!report.enabled}))}>{report?.enabled?'Turn off':'Turn on'}</button></div>
42
+ <div className="seg" role="tablist" aria-label="Memory views">{[['project','This project'],['profile','About you'],['skills','Skills'],['activity','Activity'],['providers','Context providers']].map(([id,label])=><button key={id} role="tab" aria-selected={tab===id} className={tab===id?'on':''} onClick={()=>chooseTab(id)}>{label}</button>)}</div>
43
+ {error&&<p className="error-box" role="alert">{error}</p>}
44
+ {(working||loading&&['project','profile'].includes(tab))&&<p className="memory-feedback" role="status"><BrainActivity/> Updating memory…</p>}
45
+ {report&&(report.pending>0||report.rejected>0)&&<p className="warn-text">{report.pending} pending · {report.rejected} rejected</p>}
46
+ {['profile','project'].includes(tab)&&<>
47
+ <div className="memory-toolbar"><input type="search" aria-label="Search memory" placeholder="Search memory or record ID" value={query} maxLength={200} onChange={event=>setQuery(event.target.value)}/><select aria-label="Memory status" value={status} onChange={event=>{setStatus(event.target.value);setCursor(null);setHistory([]);}}><option value="active">Current</option><option value="superseded">Earlier</option><option value="quarantined">Quarantined</option><option value="all">All</option></select><strong>{page?.total?.toLocaleString()??'—'} {page?.total===1?'record':'records'}</strong></div>
48
+ <div className="memory-table-wrap" aria-busy={loading}><table className="memory-table"><thead><tr><th>Key</th><th>Memory</th><th>Status</th><th>User</th><th>Updated</th><th><span className="sr-only">Actions</span></th></tr></thead><tbody>{page?.records?.map(record=><tr key={record.id}><td><button className="link" onClick={()=>openRecord(record)}>{record.fact_key||record.id}</button></td><td><button className="memory-content" onClick={()=>openRecord(record)}>{record.content}</button></td><td><span className={'memory-badge '+record.status}>{record.status==='active'?'Current':record.status==='superseded'?'Earlier':record.status}</span></td><td title={record.governance?.userId||'Creator was not recorded'}>{short(record.governance?.userId)}</td><td>{date(record.updated_at||record.created_at)}</td><td><button className="link" disabled={working} onClick={()=>act(async()=>{setPreview({...await memoryCall(session,'forget-preview',{...payload,scope,recordId:record.id}),scope,recordId:record.id});})}>Forget</button></td></tr>)}</tbody></table>{!loading&&!page?.records?.length&&<p className="memory-empty">{query?'No matching memories.':'No memories yet.'}</p>}</div>
49
+ <footer className="memory-pagination"><span>Page {history.length+1} · 50 per page</span><button className="btn" disabled={loading||!history.length} onClick={()=>{setCursor(history.at(-1));setHistory(value=>value.slice(0,-1));}}>Previous</button><button className="btn" disabled={loading||!page?.hasMore} onClick={()=>{setHistory(value=>[...value,cursor]);setCursor(page.nextCursor);}}>Next</button></footer>
50
+ </>}
51
+ {tab==='profile'&&<form className="memory-preference-form" onSubmit={event=>{event.preventDefault();act(async()=>{await memoryCall(session,'profile',{...payload,field,value});setValue('');});}}><select aria-label="Preference type" value={field} onChange={event=>setField(event.target.value)}><option value="preferences">Preference</option><option value="explanation">Explanation style</option><option value="experience">Role</option><option value="iac">Infrastructure tool</option></select><input aria-label="Remember about me" placeholder="Add a preference" value={value} maxLength={1000} onChange={event=>setValue(event.target.value)}/><button className="btn primary" disabled={working||!report?.enabled||!value.trim()}>Remember</button></form>}
52
+ {selected&&<section className="memory-record-detail" aria-label="Memory details"><header className="memory-heading"><h3>{selected.record.fact_key||'Memory'}</h3><button className="icon" aria-label="Close memory details" onClick={()=>setSelected(null)}>×</button></header><p>{selected.record.content}</p><dl>{[['Record',selected.record.id],['Creator user ID',selected.record.governance?.userId||'Unknown · legacy record'],['Owner user ID',selected.record.governance?.ownerUserId],['Actor',selected.record.governance?.actor||'Unknown'],['Source',selected.record.governance?.source||'Unknown'],['Created',date(selected.record.created_at)],['Updated',date(selected.record.updated_at)]].map(([label,value])=><React.Fragment key={label}><dt>{label}</dt><dd>{value||'Unknown'}</dd></React.Fragment>)}</dl>{selected.audit?.length>0&&<table className="memory-table"><thead><tr><th>Time</th><th>Actor</th><th>Action</th></tr></thead><tbody>{selected.audit.map((event,index)=><tr key={index}><td>{date(event.created_at)}</td><td>{event.actor}</td><td>{event.event_type}</td></tr>)}</tbody></table>}</section>}
53
+ {preview&&<section className="memory-preview" aria-label="Confirm memory removal"><h3>Forget memory?</h3><p>{preview.total??preview.records.length} records, including history. Project files stay.</p><button className="btn danger" disabled={working} onClick={()=>act(async()=>{await memoryCall(session,'forget',{...payload,scope:preview.scope,recordId:preview.recordId});setPreview(null);setSelected(null);})}>Forget</button><button className="btn" onClick={()=>setPreview(null)}>Cancel</button></section>}
54
+ {tab==='skills'&&<table className="memory-table"><thead><tr><th>Skill</th><th>Version</th><th>Integrity</th><th>Source</th><th>Status</th></tr></thead><tbody>{report?.skills?.map(skill=><tr key={skill.id}><td>{skill.name}</td><td>{skill.version}</td><td>{skill.integrity?'Verified':'Failed'}</td><td>{skill.source.startsWith('https:')?<a href={skill.source} target="_blank" rel="noreferrer">Source ↗</a>:'Local'}</td><td><button className="link" disabled={working||!skill.integrity} onClick={()=>act(()=>memoryCall(session,'skill',{...payload,skillId:skill.id,enabled:!skill.enabled}))}>{skill.enabled?'Disable':'Enable'}</button></td></tr>)}</tbody></table>}
55
+ {tab==='providers'&&<section aria-label="Context providers"><table className="memory-table"><thead><tr><th>Provider</th><th>Type</th><th>Status</th></tr></thead><tbody>{report?.contextProviders?.map(provider=><tr key={provider.id}><td>{provider.label}</td><td>{provider.kind}</td><td>{provider.enabled?'Enabled':'Disabled'}</td></tr>)}</tbody></table>{!report?.contextProviders?.length&&<p>No external providers configured.</p>}<p className="muted small">Connect RAG or memory through the server’s MEMORY_CONTEXT_PROVIDERS setting. Retrieved context is never saved automatically.</p></section>}
56
+ {tab==='activity'&&<table className="memory-table"><thead><tr><th>Time</th><th>Result</th><th>Records</th><th>Skills</th></tr></thead><tbody>{report?.activity?.map(activity=><tr key={activity.id}><td>{date(activity.createdAt)}</td><td>{activity.success?'Completed':'Incomplete'}</td><td>{activity.recordIds.length}</td><td>{activity.skillIds.join(', ')||'—'}</td></tr>)}</tbody></table>}
57
+ </div>;
46
58
  }
@@ -1,3 +1,6 @@
1
+ import {InlineNote} from './HelpNote.jsx';
2
+ import {X,Volume2} from 'lucide-react';
3
+ import {BrainActivity} from './BrainActivity.jsx';
1
4
  import React,{useEffect,useRef,useState} from 'react';
2
5
  import {OpenAIVoice} from './openai-voice.mjs';
3
6
 
@@ -34,29 +37,27 @@ export function OpenAIVoicePanel({session,connection,transportRef,onTranscript,o
34
37
  }});
35
38
  current.readReplies=readReplies;engine.current=current;transportRef.current=current;void current.start().then(()=>{if(live.current&&current.channel?.readyState==='open')setOpen(false)});
36
39
  }
37
- const caption={off:'Voice is off. Local dictation remains available from the microphone button.',connecting:'Connecting OpenAI audio…',listening:automatic?'Listening — completed turns go to your selected Architect. Existing drafts remain for review.':'Listening — review the transcript in chat, then Send.',speaking:'Speaking the written Architect reply.',error:'Voice stopped; continue in text or retry.'}[phase];
40
+ const caption={off:'Off',connecting:'Connecting OpenAI audio…',listening:automatic?'Listening · auto-send':'Listening',speaking:'Speaking',error:'Stopped · retry'}[phase];
38
41
  return <>
39
- <div className="voice-status"><button type="button" onClick={()=>setOpen(true)}>OpenAI voice</button>{active&&<button type="button" onClick={()=>engine.current?.stop({preservePending:true})}>Stop OpenAI voice</button>}{active&&error.includes('playback')&&<button type="button" onClick={()=>void engine.current?.resumeAudio()}>Enable voice playback</button>}{(active||error)&&<span role="status">{error||caption}</span>}</div>
42
+ <div className="voice-status"><button type="button" className="icon" aria-label="Voice settings" title="Voice settings" onClick={()=>setOpen(true)}><Volume2 size={18}/></button>{active&&<button type="button" onClick={()=>engine.current?.stop({preservePending:true})}>Stop voice</button>}{active&&error.includes('playback')&&<button type="button" onClick={()=>void engine.current?.resumeAudio()}>Enable voice playback</button>}{(active||error)&&<span role="status">{error||caption}</span>}</div>
40
43
  {open&&<div className="backdrop" onMouseDown={event=>{if(event.target===event.currentTarget)setOpen(false)}}><section className="modal" role="dialog" aria-modal="true" aria-label="OpenAI voice" onKeyDown={event=>{if(event.key==='Escape'){event.stopPropagation();setOpen(false)}}}>
41
- <h2>OpenAI voice</h2><button type="button" className="btn ghost" onClick={()=>setOpen(false)}>Close voice settings</button>
44
+ <h2>Voice</h2><button type="button" className="icon modal-close" aria-label="Close voice settings" onClick={()=>setOpen(false)}><X size={18}/></button>
42
45
  <section aria-label="OpenAI voice settings">
43
- <p>Voice preview — live OpenAI audio verification is pending.</p>
44
- <p>AI-generated audio from OpenAI. Microphone audio and spoken reply text are sent to OpenAI. Architect does not save the audio. Written chat remains the source for design decisions.</p>
45
- <p>Architecture answers still use {connection?`${connection.model}${connection.effort?' · '+connection.effort:''}`:'your selected Architect'}. The audio model is {status?.model||'loading'}.</p>
46
- {!connection&&<button type="button" className="btn" onClick={()=>{setOpen(false);onConnections()}}>Choose Architect connection</button>}
47
- {status&&<details open={!status.available}><summary>Voice API setup</summary>
48
- <p>Configure a dedicated OpenAI API key for voice. Your Architect connection stays unchanged. The server keeps this key in memory for its current session. An existing server OpenAI key can also be used.</p>
46
+ <small>AI audio · OpenAI</small><InlineNote>Audio and spoken replies go to OpenAI. Audio is not saved. Live verification pending.</InlineNote>
47
+ {!connection&&<button type="button" className="btn" onClick={()=>{setOpen(false);onConnections()}}>Choose AI provider</button>}
48
+ {status&&<details open={!status.available}><summary>API key</summary>
49
+
49
50
  <label className="dialog-field">OpenAI voice API key<input aria-label="OpenAI voice API key" type="password" autoComplete="off" value={key} onChange={event=>setKey(event.target.value)}/></label>
50
- <button type="button" className="btn" disabled={saving||active||!key.trim()} onClick={configure}>Configure voice key</button>
51
+ <button type="button" className="btn" disabled={saving||active||!key.trim()} onClick={configure}>Use key</button>
51
52
  </details>}
52
- <button type="button" className="link" disabled={saving} onClick={()=>void refresh().catch(e=>setError(e.message))}>Check voice connection</button>
53
+ <button type="button" className="link" disabled={saving} onClick={()=>void refresh().catch(e=>setError(e.message))}>Refresh</button>
53
54
  <div className="record-row">
54
- <button type="button" className="btn" disabled={!status?.available||!connection} onClick={active?()=>engine.current?.stop({preservePending:true}):start}>{active?'Stop OpenAI voice':'Start OpenAI voice'}</button>
55
+ <button type="button" className="btn" disabled={!status?.available||!connection} onClick={active?()=>engine.current?.stop({preservePending:true}):start}>{active?'Stop voice':'Start voice'}</button>
55
56
  {active&&<button type="button" className="btn" onClick={()=>engine.current?.interrupt()}>Stop speaking</button>}
56
57
  {active&&error.includes('playback')&&<button type="button" className="btn" onClick={()=>void engine.current?.resumeAudio()}>Enable voice playback</button>}
57
58
  </div>
58
- <p><label><input type="checkbox" checked={automatic} onChange={event=>setAutomatic(event.target.checked)}/> Send spoken turns automatically</label></p>
59
- <p><label><input type="checkbox" checked={readReplies} onChange={event=>{setReadReplies(event.target.checked);if(engine.current){engine.current.readReplies=event.target.checked;if(!event.target.checked)engine.current.interrupt();}}}/> Speak Architect replies</label></p>
59
+ <p><label><input type="checkbox" checked={automatic} onChange={event=>setAutomatic(event.target.checked)}/> Auto-send</label></p>
60
+ <p><label><input type="checkbox" checked={readReplies} onChange={event=>{setReadReplies(event.target.checked);if(engine.current){engine.current.readReplies=event.target.checked;if(!event.target.checked)engine.current.interrupt();}}}/> Read replies</label></p>
60
61
  <p role="status">{caption}</p>
61
62
  {error&&<p role="alert">{error}</p>}
62
63
  </section>
@@ -1,3 +1,5 @@
1
+ import {FileTree} from './FileTree.jsx';
2
+ import {strFromU8} from 'fflate';
1
3
  import {CodePreview} from './CodePreview.jsx';
2
4
  import {BrainActivity} from "./BrainActivity.jsx";
3
5
  import React,{useEffect,useState} from 'react';
@@ -33,33 +35,37 @@ function Preview({name,content}){
33
35
  if(name.endsWith('.md'))return <Document text={content}/>;
34
36
  return <CodePreview className="output-code" content={content} name={name}/>;
35
37
  }
36
- export function OutputsView({project,run,environment,session,onDownload,onBack,emulation,onDiscuss,discussionBusy,onBuild,onSave}){
38
+ export function OutputsView({project,run,environment,session,onDownload,onBack,emulation,onDiscuss,discussionBusy,onBuild,onSave,onDownloaded,onReview,checkReceipt,onNavigate}){
39
+ const [exploration,setExploration]=useState(null);
37
40
  const [bundle,setBundle]=useState(null),[error,setError]=useState(''),[loading,setLoading]=useState(true),[retry,setRetry]=useState(0),[selected,setSelected]=useState('spec-kit/implementation-spec.md');
38
41
  useEffect(()=>{
39
42
  let active=true;setLoading(true);setError('');
40
- outputBundle(project,run,environment).then(value=>{if(active){setBundle(value);setLoading(false);}}).catch(e=>{if(active){setError(e.message);setLoading(false);}});
43
+ outputBundle(project,run,environment,checkReceipt).then(value=>{if(active){setBundle(value);setLoading(false);}}).catch(e=>{if(active){setError(e.message);setLoading(false);}});
41
44
  return ()=>{active=false;};
42
- },[project,run,environment,retry]);
45
+ },[project,run,environment,retry,checkReceipt]);
43
46
  const readiness=bundle?.files['spec-kit/readiness.json']?JSON.parse(bundle.files['spec-kit/readiness.json']):null;
44
- const groups=bundle?[['Spec Kit',Object.keys(bundle.files).filter(n=>n.startsWith('spec-kit/')).sort((a,b)=>Number(b.includes('implementation-spec'))-Number(a.includes('implementation-spec')))],['Specification & handoff',Object.keys(bundle.files).filter(n=>!n.includes('/')&&!/^diagram\./.test(n))],['Design',Object.keys(bundle.files).filter(n=>/^diagram\./.test(n))],['Terraform',Object.keys(bundle.files).filter(n=>n.startsWith('terraform/'))]]:[];
47
+ const fileValues=exploration?.project===project?exploration.files:bundle?.files||{};
48
+ const selectedPath=fileValues[selected]!==undefined?selected:fileValues['START_HERE.md']?'START_HERE.md':Object.keys(fileValues).find(name=>name.endsWith('implementation-spec.md'))||Object.keys(fileValues)[0];
49
+ const raw=fileValues[selectedPath],content=typeof raw==='string'?raw:raw&&/\.(?:md|txt|json|py|js|mjs|tf|sh|yaml|yml|toml|html|css)$/.test(selectedPath)?strFromU8(raw):raw;
50
+
45
51
  return <section className="doc outputs-view" aria-label="Outputs">
46
- <header className="doc-head"><h2>Delivery</h2><p className="muted">Browse the requirements, design, infrastructure and recorded checks. After generation in Review, download one complete project package here.</p></header>
47
- <button className="link" onClick={onBack}>Back to architecture</button>
48
- {session?.engineeringEnabled&&<ImplementationDelivery key={project.id} project={project} session={session} onBuild={onBuild} onSave={onSave} contextBusy={discussionBusy}/>}
49
- <details open={!session?.engineeringEnabled}><summary>Design draft: requirements, Spec Kit and infrastructure</summary>
50
- <p>Reference documents from the current design. The final package above retains both the submitted design and the latest saved design, with any differences identified.</p>
51
- {!session?.engineeringEnabled&&<><p>Implementation generation is unavailable in this session. This package contains the design and engineering tasks; it does not contain a verified application.</p><button className="btn" disabled={!bundle||loading||!!error} onClick={()=>onDownload(bundle)}>Download design package (ZIP)</button></>}
52
+ <header className="doc-head"><h2>Delivery</h2></header>
53
+ <button className="link" onClick={onReview||onBack}>Back to Review</button>
54
+ {session?.engineeringEnabled&&<ImplementationDelivery key={project.id} project={project} session={session} onBuild={onBuild} onSave={onSave} contextBusy={discussionBusy} designBundle={loading?null:bundle} onDesignDownload={onDownload} onDownloaded={onDownloaded} checkReceipt={checkReceipt} onExplore={setExploration} onNavigate={onNavigate}/>}
55
+ <section aria-label="Project file explorer"><h3>Explore files</h3>
56
+
57
+ {!session?.engineeringEnabled&&<><p>Design package · no implementation generated.</p><button className="btn" disabled={!bundle||loading||!!error} onClick={()=>onDownload(bundle)}>Download project ZIP</button></>}
52
58
 
53
59
  {loading&&<p role="status"><BrainActivity/> Preparing the current outputs…</p>}
54
60
  {error&&<div role="alert"><p>{error}</p><button className="btn" onClick={()=>setRetry(n=>n+1)}>Retry preparing outputs</button></div>}
55
61
  {bundle&&!loading&&!error&&<>
56
- <p className="muted small">Design r{bundle.manifest.designRevision} · {Object.keys(bundle.files).length} files · Generated {new Date(bundle.manifest.generatedAt).toLocaleString()}. Changes to the project refresh this directory. Download contains these exact files.</p>
57
- {readiness&&<aside aria-label="Handoff readiness"><strong>Engineering handoff — decisions and implementation remain</strong><p>{readiness.counts.priorityDecisions} priority decisions · {readiness.counts.generatedFoundations} generated foundations / {readiness.counts.components} logical components</p><p>Next: {readiness.nextAction}</p><button className="link" onClick={()=>setSelected('spec-kit/readiness.md')}>Review handoff gaps</button></aside>}
62
+ <p className="muted small">r{bundle.manifest.designRevision} · {Object.keys(bundle.files).length} design files</p>
63
+ {readiness&&<details><summary>Remaining work</summary><aside aria-label="Handoff readiness"><strong>Engineering handoff — decisions and implementation remain</strong><p>{readiness.counts.priorityDecisions} priority decisions · {readiness.counts.generatedFoundations} generated foundations / {readiness.counts.components} logical components</p><p>Next: {readiness.nextAction}</p><button className="link" onClick={()=>setSelected('spec-kit/readiness.md')}>Review handoff gaps</button></aside></details>}
58
64
  <div className="outputs-browser" data-snapshot-id={bundle.manifest.snapshotId}>
59
- <nav className="outputs-directory" aria-label="Output files">{groups.map(([title,names])=><details key={title} open><summary>{title}</summary>{names.map(name=><button type="button" key={name} aria-current={selected===name?'page':undefined} onClick={()=>setSelected(name)} title={name}>{name.includes('/')?name.split('/').at(-1):name}</button>)}</details>)}</nav>
60
- <div className="output-preview" aria-label="Output preview"><h3>{selected}</h3>{bundle.files[selected]!==undefined&&<Preview key={selected} name={selected} content={bundle.files[selected]}/>}</div>
65
+ <FileTree files={fileValues} selected={selectedPath} onSelect={setSelected}/>
66
+ <div className="output-preview" aria-label="Output preview"><h3>{selectedPath}</h3>{content!==undefined&&(typeof content==='string'||/\.(?:pdf|png|svg)$/.test(selectedPath)?<Preview key={selectedPath} name={selectedPath} content={content}/>:<p>{content.byteLength} bytes</p>)}</div>
61
67
  </div>
62
68
  </>}
63
- </details>
69
+ </section>
64
70
  </section>;
65
71
  }
@@ -0,0 +1,44 @@
1
+ import React,{useEffect,useMemo,useRef,useState}from'react';
2
+ import{BrainActivity}from'./BrainActivity.jsx';
3
+ import{engineeringCall}from'./engineering-client.mjs';
4
+ import{terraformSketch}from'./terraform.mjs';
5
+ import{verifiedReceipt}from'./validation-receipt.mjs';
6
+ export function ProjectChecks({project,environment,session,onReceipt,onBusy,receipt}){
7
+ const [report,setReport]=useState(null),[catalog,setCatalog]=useState([]),[checks,setChecks]=useState([]),[result,setResult]=useState(null),[working,setWorking]=useState(false),[error,setError]=useState('');
8
+ const active=useRef(null),locked=useRef(false),files=useMemo(()=>terraformSketch(project,environment),[project,environment]),key=JSON.stringify(files);
9
+ useEffect(()=>{let stopped=false;setResult(null);active.current?.abort();let previous=receipt;try{previous=previous||JSON.parse(localStorage.getItem('atmem-tf-receipt:'+project.id)||'null');}catch{}void verifiedReceipt(files,previous).then(value=>{if(!stopped){setResult(value);onReceipt?.(value);}});return()=>{stopped=true;active.current?.abort();};},[key]);
10
+ useEffect(()=>{
11
+ if(!session?.engineeringEnabled)return;
12
+ let stopped=false,timer;const controller=new AbortController();
13
+ async function poll(){try{
14
+ const value=await engineeringCall(session,'delivery',{projectId:project.id},{signal:controller.signal});if(stopped)return;setReport(value);
15
+ if(value.artifact){const payload={projectId:project.id,sessionId:value.session.id};const [definitions,results]=await Promise.all([engineeringCall(session,'validators',payload,{signal:controller.signal}),engineeringCall(session,'validation/list',{...payload,artifactId:value.artifact.id},{signal:controller.signal})]);if(!stopped){setCatalog(definitions.validators||[]);setChecks(results.checks||[]);}}
16
+ }catch(e){if(!stopped&&e.name!=='AbortError'&&e.status!==404)setError(e.message);}finally{if(!stopped)timer=setTimeout(poll,2000);}}
17
+ void poll();return()=>{stopped=true;clearTimeout(timer);controller.abort();};
18
+ },[project.id,session?.token,session?.engineeringEnabled]);
19
+ const running=checks.some(c=>c.state!=='finished'&&c.state!=='recovery_required');
20
+ useEffect(()=>{onBusy?.(working||running);return()=>onBusy?.(false);},[working,running,onBusy]);
21
+ async function run(){
22
+ if(locked.current)return;locked.current=true;setWorking(true);setError('');const controller=new AbortController();active.current=controller;
23
+ try{
24
+ const jobs=[];
25
+ if(report?.artifact)for(const validator of catalog.filter(v=>v.available!==false)){
26
+ const previous=checks.filter(c=>c.validatorId===validator.id&&c.contractSha256===validator.contractSha256).at(-1);
27
+ if(previous&&previous.state!=='finished')continue;
28
+ jobs.push(engineeringCall(session,'validation/start',{projectId:project.id,sessionId:report.session.id,artifactId:report.artifact.id,validatorId:validator.id,retry:!!previous},{signal:controller.signal}));
29
+ }
30
+ const outcomesPending=Promise.allSettled(jobs);
31
+ const response=await fetch('/api/terraform/validate',{method:'POST',headers:{'Content-Type':'application/json',Authorization:`Bearer ${session.token}`},body:JSON.stringify({project,environment}),signal:AbortSignal.any([controller.signal,AbortSignal.timeout(500000)])});const value=await response.json();
32
+ if(!response.ok)throw Error(value.detail||'Terraform checks could not start.');
33
+ const verified=await verifiedReceipt(files,value);if(!controller.signal.aborted){setResult(value);onReceipt?.(verified);try{if(verified)localStorage.setItem('atmem-tf-receipt:'+project.id,JSON.stringify(verified));else localStorage.removeItem('atmem-tf-receipt:'+project.id);}catch{}}
34
+ const outcomes=await outcomesPending;const failed=outcomes.find(o=>o.status==='rejected');if(failed)throw failed.reason;
35
+ }catch(e){if(!controller.signal.aborted){setResult({status:'failed',message:e.message});setError(e.message);}}
36
+ finally{locked.current=false;setWorking(false);}
37
+ }
38
+ const label=status=>({passed:'Passed',failed:'Failed',incomplete:'Incomplete',unavailable:'Not run'}[status]||'Not run');
39
+ return <section className="project-checks" aria-label="Project checks"><div className="section-action"><h3>Checks</h3><button className="btn" disabled={!session?.token||working||running} onClick={run}>{working||running?<><BrainActivity size={22}/> Checking…</>:'Run checks'}</button></div>
40
+ <ul className="check-rows"><li><span>Terraform</span><strong className={result?.status==='passed'?'check-complete':'check-pending'}>{working?'Running…':label(result?.status)}</strong></li>{checks.map(c=><li key={c.id}><span>{c.label}</span><strong className={c.result?.status==='passed'?'check-complete':'check-pending'}>{c.state==='finished'?label(c.result?.status):c.state==='recovery_required'?'Needs recovery':'Running…'}</strong></li>)}{!checks.length&&<li><span>Application checks</span><strong className="check-pending">{report?.artifact?'Not configured':'Generate files first'}</strong></li>}</ul>
41
+ {(result||checks.length>0)&&<details><summary>Check details</summary>{result&&<><p>{result.message}</p>{result.checks?.map((c,i)=><details key={i}><summary>{c.name} · {c.passed?'Passed':'Failed'}</summary><pre>{c.output}</pre></details>)}</>}{checks.map(c=><details key={c.id}><summary>{c.label}</summary><pre>{c.result?.output||c.result?.reason||c.state}</pre></details>)}</details>}
42
+ {error&&<p role="alert">{error}</p>}
43
+ </section>;
44
+ }
@@ -154,7 +154,7 @@ export function Connections({ session, connection, onCheck, onSave, onRetry, wel
154
154
  (kind === "aws" && !capability?.available);
155
155
  return (
156
156
  <div className="connect">
157
- <h2>{welcome ? "Check your connection" : "Connection"}</h2>
157
+ <h2>{welcome ? "AI providers" : "AI providers"}</h2>
158
158
  {!session && (
159
159
  <p className="error-box">
160
160
  Looks like AtMem AI Architect's helper isn't running. Start it with <code>npm run local</code>, then{" "}
@@ -6,6 +6,7 @@ export function conversationMessages(project, fallback = []) {
6
6
  function cleanMessages(messages) {
7
7
  return messages.filter(m => m && ['user','assistant'].includes(m.role) && typeof m.text === 'string').map(m => ({
8
8
  role:m.role, text:m.text,
9
+ ...(typeof m.createdAt==='string'&&Number.isFinite(Date.parse(m.createdAt))?{createdAt:m.createdAt}:{}),
9
10
  ...(typeof m.activityId==='string'&&/^[a-f0-9-]{36}$/.test(m.activityId)?{activityId:m.activityId}:{}),
10
11
  ...(m.requestId?{requestId:String(m.requestId).slice(0,100)}:{}),
11
12
  ...(['queued','processing','complete','superseded','failed','cancelled'].includes(m.workState)?{workState:m.workState}:{}),
package/src/delivery.mjs CHANGED
@@ -20,7 +20,7 @@ const json = (v) => JSON.stringify(v, null, 2);
20
20
  const list = (items) => items.length ? items.map((x) => `- ${x}`).join("\n") : "None recorded.";
21
21
  export function solutionSpecification(project) {
22
22
  const a = project.assessment;
23
- return `# ${project.name} — solution specification\n\n## Problem and intended outcome\n\n${implementationOutcome(project)}\n\n## Scope\n\n${list((project.useCases || []).map((u) => `${u.title}: ${u.verdict}${u.reason ? ` — ${u.reason}` : ""}`))}\n\n## Requirements and acceptance criteria\n\n${list((project.needs || []).map((r) => `[${requirementNumber(project,r.id)}] ${r.statement} (${r.priority}, ${r.status}); target: ${r.measure ? `${r.measure.metric} ${r.measure.op} ${r.measure.target}${r.measure.unit || ""}` : "acceptance target needed"}; linked blocks: ${r.links?.join(", ") || "not linked"}${`\n\nDesign explanation: ${requirementExplanation(r)}`}${r.basis ? `\n\nBasis: ${r.basis}` : ""}`))}\n\n${systemDescriptionMarkdown(project)}\n\n## Architecture\n\n\`\`\`mermaid\n${mermaid(project)}\n\`\`\`\n\n${resourceMarkdown(project)}\n\n## Options and tradeoffs\n\n${a ? `Scores are entered judgements (1–5), not provider benchmarks or prices. Unscored options remain open.\n\n| Option | ${CRITERIA.map((c) => c.label).join(" | ")} | Weighted score | Rationale |\n|---|${CRITERIA.map(() => "---|").join("")}---|---|\n${OPTIONS.map((o) => `| ${OPTION_NAMES[o]} | ${CRITERIA.map((c) => a.scores?.[o]?.[c.id] ?? "—").join(" | ")} | ${optionScore(a, o) ?? "—"} | ${text(a.notes?.[o])} |`).join("\n")}` : "Use Design → Options to compare cloud choices and record the rationale before choosing."}\n\n## Decisions and change history\n\n${adrMarkdown(project.decisions)}\n\n## Assumptions\n\n${list((project.assumptions || []).map((a) => `[${a.id}] ${a.statement} (${a.madeAt})`))}\n\n## Questions to resolve\n\n${list((project.openQuestions || []).filter((q) => q.status === "open").map((q) => `[${q.id}] ${q.question}${q.owner ? ` — ${q.owner}` : ""}`))}\n`;
23
+ return `# ${project.name} — solution specification\n\n## Problem and intended outcome\n\n${implementationOutcome(project)}\n\n## Scope\n\n${list((project.useCases || []).map((u) => `${u.title}: ${u.verdict}${u.reason ? ` — ${u.reason}` : ""}`))}\n\n## Requirements and acceptance criteria\n\n${list((project.needs || []).map((r) => `[${requirementNumber(project,r.id)}] ${r.statement} (${r.priority}, ${r.status}); target: ${r.measure ? `${r.measure.metric} ${r.measure.op} ${r.measure.target}${r.measure.unit || ""}` : "acceptance target needed"}; linked blocks: ${r.links?.join(", ") || "not linked"}${`\n\nDesign explanation: ${requirementExplanation(r)}`}${r.basis ? `\n\nBasis: ${r.basis}` : ""}`))}\n\n${systemDescriptionMarkdown(project)}\n\n## Architecture\n\n\`\`\`mermaid\n${mermaid(project)}\n\`\`\`\n\n${resourceMarkdown(project)}\n\n## Options and tradeoffs\n\n${a ? `Scores are entered judgements (1–5), not provider benchmarks or prices. Unscored options remain open.\n\n| Option | ${CRITERIA.map((c) => c.label).join(" | ")} | Weighted score | Rationale |\n|---|${CRITERIA.map(() => "---|").join("")}---|---|\n${OPTIONS.map((o) => `| ${OPTION_NAMES[o]} | ${CRITERIA.map((c) => a.scores?.[o]?.[c.id] ?? "—").join(" | ")} | ${optionScore(a, o) ?? "—"} | ${text(a.notes?.[o])} |`).join("\n")}` : "Use Design → Options to compare cloud choices and record the rationale before choosing."}\n\n## Design checks and decisions\n\n${adrMarkdown(project.decisions)}\n\n### Design checks\n\n${review(project).map(f=>`- [${f.status}] ${f.statement}\n Rationale: ${f.rationale}\n Next action: ${f.action}\n Source: ${f.source}${project.reviewAccepted?.[f.key]?`\n Accepted by: ${project.reviewAccepted[f.key].owner}; reason: ${project.reviewAccepted[f.key].reason}`:''}`).join('\n\n')}\n\n## Assumptions\n\n${list((project.assumptions || []).map((a) => `[${a.id}] ${a.statement} (${a.madeAt})`))}\n\n## Questions to resolve\n\n${list((project.openQuestions || []).filter((q) => q.status === "open").map((q) => `[${q.id}] ${q.question}${q.owner ? ` — ${q.owner}` : ""}`))}\n`;
24
24
  }
25
25
 
26
26
  export function deliveryPack(project, run, environment = "staging", now = new Date()) {
package/src/estimate.mjs CHANGED
@@ -79,8 +79,8 @@ export function estimate(project, now = new Date()) {
79
79
  const priced = validNumber(l.price) && /^[A-Z]{3}$/.test(l.currency || "") && l.source?.trim() && l.unit?.trim();
80
80
  lines.push({ id: l.id, component: n.data.label, componentId: n.id, detailed: true,
81
81
  driver: l.description || "Charge", unit: l.unit, inputs: [], quantity: missing.length ? null : l.quantity,
82
- missing, price: priced ? l : null,
83
- cost: !missing.length && priced ? Math.round(l.quantity * l.price * 100) / 100 : null,
82
+ missing, note:l.note, price: priced ? l : null,
83
+ cost: !missing.length && priced ? l.quantity * l.price : null,
84
84
  status: missing.length ? "input needed" : priced ? "priced" : "price needed" });
85
85
  }
86
86
  continue;
@@ -103,7 +103,7 @@ export function estimate(project, now = new Date()) {
103
103
  quantity,
104
104
  missing,
105
105
  price: priced ? price : null,
106
- cost: quantity != null && priced ? Math.round(quantity * price.price * 100) / 100 : null,
106
+ cost: quantity != null && priced ? quantity * price.price : null,
107
107
  status: missing.length ? "input needed" : priced ? "priced" : "price needed",
108
108
  });
109
109
  }
@@ -115,6 +115,7 @@ export function estimate(project, now = new Date()) {
115
115
  const gaps = [
116
116
  ...(est.details || []).filter((l) => !project.nodes.some((n) => n.id === l.componentId && n.data.kind !== "note")).map((l) => `${l.description || l.id}: linked component was removed; reassign this charge (excluded from totals).`),
117
117
  ...lines.filter((l) => l.status !== "priced").map((l) => `${l.component} / ${l.driver}: ${l.status}${l.missing.length ? ` (${l.missing.join(", ")})` : ""}.`),
118
+ ...lines.filter(l=>l.price?.coverageIncomplete).map(l=>`${l.component}: additional charges are excluded from this estimate.`),
118
119
  ...notCosted.map((name) => `${name}: no cost lines; add charges or an explicit zero-cost line with a reason.`),
119
120
  ...(currencies.length > 1 ? ["Choose one currency; different currencies cannot be added."] : []),
120
121
  ...lines.filter((l) => l.price && (!l.price.region?.trim() || !l.price.service?.trim() || !l.price.date || !Number.isFinite(Date.parse(l.price.date)))).map((l) => `${l.component} / ${l.driver}: confirm region, service/SKU and price date.`),
@@ -124,6 +125,7 @@ export function estimate(project, now = new Date()) {
124
125
  return {
125
126
  lines,
126
127
  notCosted,
128
+ preciseExpected:costed.length&&currencies.length===1?total:null,
127
129
  usage: u,
128
130
  currency: currencies.length === 1 ? currencies[0] : null,
129
131
  mixedCurrencies: currencies.length > 1,
@@ -0,0 +1,34 @@
1
+ import{unzipSync,zipSync,strToU8,strFromU8}from'fflate';
2
+ const safe=name=>name&&!name.startsWith('/')&&!name.includes('\\')&&!name.split('/').some(p=>p==='..'||p==='.')&&!name.includes('\0');
3
+ function attributes(bytes){
4
+ const view=new DataView(bytes.buffer,bytes.byteOffset,bytes.byteLength);let end=bytes.length-22;
5
+ while(end>=Math.max(0,bytes.length-65557)&&view.getUint32(end,true)!==0x06054b50)end--;
6
+ if(end<0||end+22+view.getUint16(end+20,true)!==bytes.length||view.getUint16(end+4,true)||view.getUint16(end+6,true))throw Error('Unsupported package directory.');
7
+ const result=new Map(),count=view.getUint16(end+10,true);let offset=view.getUint32(end+16,true);
8
+ for(let i=0;i<count;i++){
9
+ if(offset+46>end||view.getUint32(offset,true)!==0x02014b50)throw Error('Invalid package directory.');
10
+ const length=view.getUint16(offset+28,true),extra=view.getUint16(offset+30,true),comment=view.getUint16(offset+32,true);
11
+ if(offset+46+length+extra+comment>end)throw Error('Invalid package entry.');
12
+ const name=strFromU8(bytes.subarray(offset+46,offset+46+length));if(!safe(name)||result.has(name))throw Error('Unsafe or duplicate package entry.');
13
+ result.set(name,{os:view.getUint16(offset+4,true)>>>8,attrs:view.getUint32(offset+38,true)});offset+=46+length+extra+comment;
14
+ }
15
+ if(offset!==end)throw Error('Package directory does not match its receipt.');return result;
16
+ }
17
+ export async function completeProjectZip(verified,design){
18
+ if(!design?.files)throw Error('Wait for the design documents to finish preparing.');
19
+ const bytes=new Uint8Array(await verified.arrayBuffer()),attrs=attributes(bytes);let expanded=0;
20
+ const original=unzipSync(bytes,{filter:entry=>{expanded+=entry.originalSize;if(expanded>150000000)throw Error('Package exceeds its receiving limit.');return true;}});
21
+ const output=Object.fromEntries(Object.entries(original).map(([name,data])=>[name,[data,attrs.get(name)]]));
22
+ const documents={};for(const[name,value]of Object.entries(design.files)){
23
+ if(!safe(name))throw Error('Invalid design document path.');
24
+ const target='design-documents/'+name;if(output[target])throw Error('Design documents conflict with captured files.');
25
+ const content=typeof value==='string'?strToU8(value):value;expanded+=content.byteLength;if(expanded>150000000)throw Error('Combined package exceeds its receiving limit.');
26
+ output[target]=[content,{os:3,attrs:0o100644<<16}];documents[target]=content;
27
+ }
28
+ const hash=async data=>[...new Uint8Array(await crypto.subtle.digest('SHA-256',data))].map(b=>b.toString(16).padStart(2,'0')).join('');
29
+ const manifest={version:1,designRevision:design.manifest.designRevision,sourceArtifactPreserved:'evidence/original-implementation.zip',documents:await Promise.all(Object.entries(documents).map(async([path,data])=>({path,bytes:data.byteLength,sha256:await hash(data)})))};
30
+ output['package-contents.json']=strToU8(JSON.stringify(manifest,null,2));
31
+ const readme=strFromU8(original['START_HERE.md']||new Uint8Array());
32
+ output['START_HERE.md']=strToU8('# Open this project\n\nUnzip this package, then open this folder as a project in Orca or your editor. Read this file and the implementation README before running commands.\n\n- implementation/: captured application, infrastructure and tests.\n- design-documents/: requirements, Spec Kit, diagram, cost assumptions and generated design references.\n- evidence/: captured source ZIP and recorded checks.\n\nDesign references are not proof that changed requirements were implemented.\n\n'+readme);
33
+ return new Blob([zipSync(output)],{type:'application/zip'});
34
+ }
@@ -70,3 +70,37 @@
70
70
  }
71
71
 
72
72
  .chat-width-toggle{display:none}@media(min-width:901px){.chat-width-toggle{display:inline-flex;margin-left:auto;padding:5px 8px}}
73
+
74
+ .review-costs,.review-generation{padding:20px;border:1px solid var(--line);border-radius:16px;background:var(--paper);margin:16px 0}
75
+ .review-costs>.doc{padding:0}.review-costs h2,.review-generation h2{font-size:1.25rem;margin-top:0}
76
+ .review-finish{display:flex;align-items:center;justify-content:space-between;gap:20px;padding:20px 0;border-top:1px solid var(--line)}
77
+ .review-finish p{margin:5px 0;color:var(--muted);font-size:.9rem}.review-finish>.btn{flex-shrink:0}
78
+ .chat-working-status{display:flex;align-items:center;gap:10px;padding:12px 16px;border-top:1px solid var(--line);background:var(--paper);font-size:.9rem}
79
+ .chat-working-status .brain-activity{flex-shrink:0}.generation-status{display:flex;gap:8px;align-items:center}
80
+ .engineering-panel .generation-conversation{max-height:none;overflow:visible}.engineering-panel .generation-conversation[open]>.engineering-conversation{max-height:300px;overflow:auto}
81
+ .review-generation .engineering-panel textarea{min-height:72px}.review-generation .engineering-actions{display:block}
82
+ @media(max-width:650px){.review-finish{align-items:stretch;flex-direction:column}.review-finish>.btn{width:100%}}
83
+ .chat-jump-latest{position:absolute;right:24px;bottom:230px;z-index:40;border:1px solid var(--line);border-radius:50%;background:var(--paper);color:var(--ink);box-shadow:0 3px 12px #0002;width:40px;height:40px;font-size:24px;cursor:pointer}
84
+ .project-checks{padding:20px;border:1px solid var(--line);border-radius:16px;background:var(--paper);margin:16px 0}.section-action{display:flex;align-items:center;gap:8px}.section-action h3{margin:0}.section-action>.btn{margin-left:auto}
85
+ .check-rows,.final-checklist ul{padding:0;list-style:none}.check-rows li,.final-checklist li{display:flex;align-items:center;gap:12px;padding:10px 0;border-bottom:1px solid var(--line);font-size:.9rem}.check-rows strong,.final-checklist strong{margin-left:auto;font-size:.85rem}.check-complete{color:#217143}.check-pending{color:#946000}.final-checklist{margin:20px 0}.final-checklist li>span:first-child{width:24px;height:24px;display:grid;place-items:center;border:1px solid currentColor;border-radius:50%;flex-shrink:0}
86
+ [data-theme=dark] .check-complete{color:#80d6a2}[data-theme=dark] .check-pending{color:#f0c575}
87
+ .project-checks pre{max-height:300px;overflow:auto;white-space:pre-wrap}.delivery-state{font-weight:600}
88
+ .message-list>.msg{border-top:1px solid var(--line);padding-top:14px;margin-top:14px}.message-meta{display:flex;align-items:center;justify-content:space-between;gap:12px;font-size:.75rem;color:var(--muted);margin-bottom:6px}.message-meta time{font-variant-numeric:tabular-nums}
89
+ .memory-modal{width:min(1280px,96vw)!important;max-width:1280px!important;height:min(88vh,1000px);overflow:auto}.memory-panel{width:100%}
90
+ .file-tree{min-width:0;overflow:auto;border-right:1px solid var(--line);padding:8px 0;max-height:650px;background:var(--paper)}.file-tree ul{list-style:none;padding:0;margin:0}.file-tree button{display:flex;align-items:center;gap:5px;width:100%;min-height:30px;text-align:left;background:transparent;color:var(--ink);border:0;border-radius:0;font:inherit;font-size:.8rem;cursor:pointer;white-space:nowrap}.file-tree button:hover{background:var(--accent-soft)}.file-tree button.selected{background:var(--accent-soft);box-shadow:inset 2px 0 var(--accent)}.file-tree button>svg{flex-shrink:0}.tree-indent{width:13px;flex-shrink:0}.file-tree button>span:last-child{overflow:hidden;text-overflow:ellipsis}
91
+ .concise-answer{min-width:0}.message-details{display:inline-flex;align-items:center;min-height:32px;padding:2px 0;text-decoration:underline;text-underline-offset:3px;cursor:pointer;font-size:.85rem}
92
+
93
+
94
+ .final-checklist li{padding:0}.final-checklist li>button{display:flex;align-items:center;gap:12px;width:100%;padding:12px 4px;border:0;border-radius:6px;background:transparent;color:var(--ink);font:inherit;text-align:left;cursor:pointer}.final-checklist li>button:hover,.final-checklist li>button:focus-visible{background:var(--accent-soft);outline:2px solid var(--ink);outline-offset:-2px}.final-checklist button>span:first-child{width:24px;height:24px;display:grid;place-items:center;border:1px solid currentColor;border-radius:50%;flex-shrink:0}.checklist-arrow{font-size:1.15em}
95
+
96
+ .inline-note{display:block;max-width:100%;margin:6px 0 10px;line-height:1.5;color:var(--muted);overflow-wrap:anywhere}
97
+
98
+ .cost-size-selector .btn{display:inline-flex;align-items:center;gap:6px}.cost-size-selector .btn[aria-pressed="true"]{font-weight:600}.cost-selection-status{font-size:.9rem;color:var(--muted)}
99
+
100
+ .cost-heading{display:flex;align-items:center;justify-content:space-between;gap:12px}.cost-heading h2{margin:0}.cost-heading>.btn{flex-shrink:0}
101
+
102
+ .memory-heading{display:flex;align-items:center;justify-content:space-between;gap:12px}.memory-heading h2,.memory-heading h3{margin:0}.memory-profile-bar{display:flex;align-items:center;gap:12px;flex-wrap:wrap;padding:12px 0;border-bottom:1px solid var(--line);margin-bottom:12px}.memory-profile-bar label{display:flex;align-items:center;gap:8px}.memory-profile-bar input{max-width:190px}.memory-profile-bar code{font-size:.72rem;overflow-wrap:anywhere}.memory-state{margin-left:auto}.memory-toolbar{display:flex;gap:10px;align-items:center;margin:16px 0 10px}.memory-toolbar input{flex:1;min-width:100px}.memory-toolbar strong{white-space:nowrap;font-size:.85rem}.memory-table-wrap{max-height:50vh;overflow:auto;border:1px solid var(--line);border-radius:10px}.memory-table{width:100%;border-collapse:collapse;font-size:.8rem;table-layout:fixed}.memory-table th{position:sticky;top:0;background:var(--paper);z-index:1;font-weight:600;text-align:left}.memory-table td,.memory-table th{padding:10px 12px;border-bottom:1px solid var(--line);vertical-align:top;overflow-wrap:anywhere}.memory-table th:first-child{width:20%}.memory-table th:nth-child(2){width:35%}.memory-table th:last-child{width:60px}.memory-table td:nth-child(5){font-variant-numeric:tabular-nums;font-size:.73rem}.memory-content{display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden;border:0;padding:0;background:none;color:var(--ink);font:inherit;text-align:left;cursor:pointer}.memory-table .link{padding:0;font:inherit;text-align:left;overflow-wrap:anywhere}.memory-badge{font-size:.72rem;border-radius:20px;padding:3px 6px;background:var(--board)}.memory-badge.active{color:#21633a;background:#e8f4eb}.memory-pagination{display:flex;align-items:center;justify-content:flex-end;gap:10px;padding:12px 0}.memory-pagination>span{margin-right:auto;font-size:.8rem}.memory-preference-form{display:flex;gap:10px;flex-wrap:wrap;padding:12px 0}.memory-preference-form input{flex:1;min-width:140px}.memory-record-detail{padding:16px;border:1px solid var(--line);border-radius:10px;margin:12px 0}.memory-record-detail dl{display:grid;grid-template-columns:140px 1fr;gap:8px;font-size:.8rem}.memory-record-detail dd{margin:0;overflow-wrap:anywhere}.memory-record-detail p{white-space:pre-wrap}.memory-feedback{display:flex;align-items:center;gap:6px;font-size:.8rem;margin:8px 0}.memory-empty{padding:32px;text-align:center;color:var(--muted)}.memory-table-wrap[aria-busy=true]{opacity:.65}.memory-preview{padding:16px;border:1px solid var(--line);border-radius:10px}.memory-preview .btn{margin-right:8px}@media(max-width:700px){.memory-table{min-width:800px}.memory-profile-bar code{font-size:.65rem}.memory-record-detail dl{grid-template-columns:1fr}.memory-toolbar{flex-wrap:wrap}}
103
+
104
+ .memory-modal{height:auto!important;max-height:88vh!important}.memory-profile-bar input,.memory-toolbar input,.memory-toolbar select,.memory-preference-form input,.memory-preference-form select{font:inherit;font-size:.85rem;min-height:36px;box-sizing:border-box;padding:7px 10px;border:1px solid var(--line);border-radius:8px;background:var(--paper);color:var(--ink)}.memory-table th:last-child{width:84px;white-space:nowrap}.memory-table td:last-child{white-space:nowrap}.memory-table th:nth-child(4){width:90px}.memory-table th:nth-child(5){width:130px}.memory-table th:nth-child(3){width:84px}.memory-toolbar>strong{color:var(--muted);font-weight:500}.memory-pagination .btn{min-height:32px}.memory-record-detail .memory-table{table-layout:auto}.memory-table .memory-content:hover{text-decoration:underline;text-underline-offset:3px}
105
+
106
+ .location-region-form{display:flex;align-items:flex-end;gap:12px}.location-region-form label{display:flex;flex-direction:column;gap:6px}.location-region-form input{font:inherit;padding:9px 12px;border:1px solid var(--line);border-radius:8px;background:var(--paper);color:var(--ink)}