@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.
Files changed (66) hide show
  1. package/LICENSE +202 -0
  2. package/NOTICE +6 -0
  3. package/README.md +21 -21
  4. package/cli/runtime.mjs +3 -1
  5. package/dist/assets/{index-qtCM2D7O.js → index-D4CZ_n8f.js} +19166 -19094
  6. package/dist/assets/index-DuveVTIx.css +1 -0
  7. package/dist/assets/{index.es-C6C0sneB.js → index.es-Fcqg-orY.js} +1 -1
  8. package/dist/assets/{visual-export-DjbjgQbA.js → visual-export-4PDDTAP7.js} +3 -3
  9. package/dist/index.html +2 -2
  10. package/dist/licenses/atmem-APACHE-2.0.txt +202 -0
  11. package/dist/licenses/memory-dependency-notices.txt +149 -2241
  12. package/package.json +3 -3
  13. package/public/licenses/atmem-APACHE-2.0.txt +202 -0
  14. package/public/licenses/memory-dependency-notices.txt +149 -2241
  15. package/server/app.py +1 -1
  16. package/server/codex_app_server.py +5 -5
  17. package/server/codex_worker_policy.py +27 -4
  18. package/server/engineering_artifacts.py +7 -1
  19. package/server/engineering_handoff.py +9 -0
  20. package/server/engineering_routes.py +16 -2
  21. package/server/local_providers.py +7 -3
  22. package/server/memory-requirements.lock +6 -6
  23. package/server/memory-requirements.txt +2 -2
  24. package/server/memory_context.py +58 -0
  25. package/server/memory_routes.py +67 -8
  26. package/server/memory_service.py +40 -5
  27. package/server/memory_worker.py +76 -11
  28. package/server/orca_handoff.py +90 -0
  29. package/server/pricing.py +161 -1
  30. package/server/prompts.py +13 -0
  31. package/server/request_uploads.py +65 -0
  32. package/server/terraform_routes.py +47 -1
  33. package/server/windows_snapshot.py +119 -0
  34. package/src/ArchitectureWorkPanel.jsx +0 -7
  35. package/src/ArtifactChecks.jsx +2 -2
  36. package/src/ChatMessage.jsx +12 -2
  37. package/src/EngagementViews.jsx +18 -16
  38. package/src/EngineeringPanel.jsx +35 -28
  39. package/src/FileTree.jsx +7 -0
  40. package/src/FinalChecklist.jsx +23 -0
  41. package/src/HelpNote.jsx +3 -0
  42. package/src/ImplementationDelivery.jsx +56 -17
  43. package/src/JourneyViews.jsx +20 -17
  44. package/src/LocationChoices.jsx +12 -0
  45. package/src/MemoryPanel.jsx +43 -31
  46. package/src/OpenAIVoicePanel.jsx +15 -14
  47. package/src/OutputsView.jsx +28 -16
  48. package/src/ProjectChecks.jsx +58 -0
  49. package/src/StarPrompt.jsx +26 -0
  50. package/src/WorkbenchDialogs.jsx +1 -1
  51. package/src/conversation.mjs +1 -0
  52. package/src/delivery.mjs +1 -1
  53. package/src/engineering.css +10 -0
  54. package/src/estimate.mjs +5 -3
  55. package/src/final-package.mjs +34 -0
  56. package/src/journey-workspace.css +39 -0
  57. package/src/journey.mjs +12 -2
  58. package/src/large-request.mjs +25 -0
  59. package/src/main.jsx +129 -98
  60. package/src/output-bundle.mjs +18 -4
  61. package/src/pricing.mjs +59 -3
  62. package/src/project-file.mjs +1 -1
  63. package/src/useChatScroll.jsx +5 -4
  64. package/src/useEngineeringStatus.mjs +17 -0
  65. package/src/validation-receipt.mjs +1 -1
  66. 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, onChooseRegion }) {
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.expected!==null?'Estimate ready':'Prices unavailable'}</p>}{pricing?.errors?.map((error,i)=><p className={e.expected!==null?"muted small":"warn-text"} key={i}>{error.component&&`${error.component}: `}{error.reason} <button className="link" disabled={busy||pricing?.busy} onClick={()=>error.action==='choose-region'?onChooseRegion?.():onEstimate?.(project)}>{error.action==='choose-region'?'Choose region':'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>
@@ -117,8 +119,8 @@ export function EstimateView({ project, result, onChange, onEstimate, busy=false
117
119
  </div>
118
120
  <div>
119
121
  <small className="muted">Status</small>
120
- <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"}
122
+ <span className={e.mixedCurrencies ? "warn-text" : e.expected!==null ? "" : "muted"}>
123
+ {e.mixedCurrencies ? "Choose one currency" : e.expected!==null ? "Planning estimate" : "Not estimated"}
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,6 +1,8 @@
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
- import React,{useEffect,useState} from 'react';
5
+ import React,{useEffect,useState,useRef,useMemo} from 'react';
4
6
  import {EmulationPanel} from './EmulationPanel.jsx';
5
7
  import {outputBundle} from './output-bundle.mjs';
6
8
  import {ImplementationDelivery} from './ImplementationDelivery.jsx';
@@ -33,33 +35,43 @@ 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);
40
+ const projectRef=useRef(project);projectRef.current=project;
41
+ const [packing,setPacking]=useState(false);
37
42
  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
43
  useEffect(()=>{
39
44
  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);}});
45
+ outputBundle(project,run,environment,checkReceipt,{deferVisuals:true}).then(value=>{if(active){setBundle(value);setLoading(false);}}).catch(e=>{if(active){setError(e.message);setLoading(false);}});
41
46
  return ()=>{active=false;};
42
- },[project,run,environment,retry]);
47
+ },[project,run,environment,retry,checkReceipt]);
43
48
  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/'))]]:[];
49
+ const artifactMode=exploration?.project===project,prefix=artifactMode?'design-documents/':'';
50
+ const fileValues=useMemo(()=>artifactMode?{...exploration.files,...Object.fromEntries(Object.entries(bundle?.files||{}).map(([name,value])=>[prefix+name,value]))}:bundle?.files||{},[artifactMode,exploration,bundle,prefix]);
51
+ const treeFiles=useMemo(()=>({...Object.fromEntries((bundle?.visualPaths||[]).map(path=>[prefix+path,null])),...fileValues}),[bundle,fileValues,prefix]);
52
+ async function selectFile(name){setSelected(name);if(treeFiles[name]===null){try{const full=await bundle.prepare();if(projectRef.current===project)setBundle(full);}catch(error){if(projectRef.current===project)setError(error.message);}}}
53
+ async function downloadDesign(){if(packing)return;setPacking(true);setError('');try{const full=await bundle.prepare();if(projectRef.current!==project)return;setBundle(full);await onDownload(full);}catch(error){if(projectRef.current===project)setError(error.message);}finally{setPacking(false);}}
54
+ const selectedPath=Object.hasOwn(treeFiles,selected)?selected:fileValues['START_HERE.md']?'START_HERE.md':Object.keys(fileValues).find(name=>name.endsWith('implementation-spec.md'))||Object.keys(fileValues)[0];
55
+ const raw=treeFiles[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;
56
+
45
57
  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></>}
58
+ <header className="doc-head"><h2>Delivery</h2></header>
59
+ <button className="link" onClick={onReview||onBack}>Back to Review</button>
60
+ {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}/>}
61
+ <section aria-label="Project file explorer"><h3>Explore files</h3>
62
+
63
+ {!session?.engineeringEnabled&&<><p>Design package · no implementation generated.</p><button className="btn" disabled={!bundle||loading||!!error||packing} onClick={downloadDesign}>{packing?'Preparing ZIP…':'Download project ZIP'}</button></>}
52
64
 
53
65
  {loading&&<p role="status"><BrainActivity/> Preparing the current outputs…</p>}
54
66
  {error&&<div role="alert"><p>{error}</p><button className="btn" onClick={()=>setRetry(n=>n+1)}>Retry preparing outputs</button></div>}
55
67
  {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>}
68
+ <p className="muted small">r{bundle.manifest.designRevision} · {Object.keys(bundle.files).length} design files</p>
69
+ {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
70
  <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>
71
+ <FileTree files={treeFiles} selected={selectedPath} onSelect={selectFile}/>
72
+ <div className="output-preview" aria-label="Output preview"><h3>{selectedPath}</h3>{raw===null?<p role="status"><BrainActivity/> Preparing preview…</p>:content!==undefined&&(typeof content==='string'||/\.(?:pdf|png|svg)$/.test(selectedPath)?<Preview key={selectedPath} name={selectedPath} content={content}/>:<p>{content.byteLength} bytes</p>)}</div>
61
73
  </div>
62
74
  </>}
63
- </details>
75
+ </section>
64
76
  </section>;
65
77
  }
@@ -0,0 +1,58 @@
1
+ import React,{useEffect,useMemo,useRef,useState,useId}from'react';
2
+ import{postLargeJson}from'./large-request.mjs';
3
+ import{BrainActivity}from'./BrainActivity.jsx';
4
+ import{engineeringCall}from'./engineering-client.mjs';
5
+ import{terraformSketch}from'./terraform.mjs';
6
+ import{verifiedReceipt}from'./validation-receipt.mjs';
7
+ export function ProjectChecks({project,environment,session,onReceipt,onBusy,receipt,generationPending=false}){
8
+ const [report,setReport]=useState(null),[catalogReady,setCatalogReady]=useState(false),[catalog,setCatalog]=useState([]),[checks,setChecks]=useState([]),[result,setResult]=useState(null),[working,setWorking]=useState(false),[error,setError]=useState('');
9
+ const hintId=useId();
10
+ const [uploadProgress,setUploadProgress]=useState(null);
11
+ const active=useRef(null),locked=useRef(false),files=useMemo(()=>terraformSketch(project,environment),[project,environment]),key=JSON.stringify(files);
12
+ 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]);
13
+ useEffect(()=>{
14
+ setReport(null);setCatalog([]);setChecks([]);setCatalogReady(false);
15
+ if(!session?.engineeringEnabled)return;
16
+ let stopped=false,timer;const controller=new AbortController();
17
+ async function poll(){try{
18
+ const value=await engineeringCall(session,'delivery',{projectId:project.id},{signal:controller.signal});if(stopped)return;setReport(value);
19
+ 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||[]);setCatalogReady(true);}}else{setCatalog([]);setChecks([]);setCatalogReady(false);}
20
+ }catch(e){if(!stopped&&e.name!=='AbortError'&&e.status!==404)setError(e.message);}finally{if(!stopped)timer=setTimeout(poll,2000);}}
21
+ void poll();return()=>{stopped=true;clearTimeout(timer);controller.abort();};
22
+ },[project.id,session?.token,session?.engineeringEnabled]);
23
+ const running=checks.some(c=>c.state!=='finished'&&c.state!=='recovery_required');
24
+ useEffect(()=>{onBusy?.(!!working||running);return()=>onBusy?.(false);},[working,running,onBusy]);
25
+ const available=catalog.filter(v=>v.available!==false);
26
+ async function run(kind){
27
+ if(generationPending||locked.current||!session?.token||kind==='application'&&(!report?.artifact||!available.length))return;
28
+ locked.current=true;setWorking(kind);setError('');const controller=new AbortController();active.current=controller;
29
+ try{
30
+ if(kind==='application'){
31
+ const payload={projectId:project.id,sessionId:report.session.id,artifactId:report.artifact.id};
32
+ await Promise.all(available.map(validator=>{
33
+ const previous=checks.filter(c=>c.validatorId===validator.id&&c.contractSha256===validator.contractSha256).at(-1);
34
+ if(previous&&previous.state!=='finished')return;
35
+ return engineeringCall(session,'validation/start',{...payload,validatorId:validator.id,retry:!!previous},{signal:controller.signal});
36
+ }));
37
+ const results=await engineeringCall(session,'validation/list',payload,{signal:controller.signal});
38
+ if(!controller.signal.aborted)setChecks(results.checks||[]);
39
+ }else{
40
+ const value=await postLargeJson('/api/terraform/validate',{project,environment},{token:session.token,signal:AbortSignal.any([controller.signal,AbortSignal.timeout(500000)]),onProgress:setUploadProgress});
41
+ 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{}}
42
+ }
43
+ }catch(e){if(!controller.signal.aborted){setError(e.message);}}
44
+ finally{locked.current=false;setWorking(false);setUploadProgress(null);}
45
+ }
46
+ const label=status=>({passed:'Passed',failed:'Failed',incomplete:'Incomplete',unavailable:'Not run'}[status]||'Not run');
47
+ const terraformHint=generationPending?'Project files are being generated. Wait until generation finishes.':'Validate the Terraform draft from your saved design.';
48
+ const applicationHint=generationPending?'Project files are being generated. Wait until generation finishes.':!report?.artifact?'Generate project files first.':!catalogReady?'Loading available application tests.':!available.length?'No application tests are configured.':'Run the generated application tests.';
49
+ const applicationStatus=generationPending?'Generating files…':!report?.artifact?'Generate files first':working==='application'||running?'Running…':checks.length?checks.every(c=>c.state==='finished'&&c.result?.status==='passed')?'Passed':'Needs attention':!catalogReady?'Loading…':available.length?'Not run':'Not configured';
50
+ return <section className="project-checks" aria-label="Project checks"><div className="section-action"><h3>Checks</h3></div>
51
+ <ul className="check-rows">
52
+ <li><span>Terraform validation</span><strong className={result?.status==='passed'?'check-complete':'check-pending'}>{working==='terraform'?uploadProgress!==null?`Uploading ${uploadProgress}%`:'Running…':label(result?.status)}</strong><span className="check-run-action" title={terraformHint} tabIndex={generationPending?0:undefined} aria-label={terraformHint}><button className="btn" aria-label="Run Terraform validation" title={terraformHint} disabled={generationPending||!session?.token||!!working||running} onClick={()=>run('terraform')}>{working==='terraform'?<><BrainActivity size={18}/> Running…</>:'Run'}</button></span></li>
53
+ <li><span>Application tests</span><strong id={hintId} className={applicationStatus==='Passed'?'check-complete':'check-pending'}>{applicationStatus}</strong><span className="check-run-action" title={applicationHint} tabIndex={generationPending||!report?.artifact||!available.length?0:undefined} aria-label={applicationHint}><button className="btn" aria-label="Run application tests" aria-describedby={hintId} title={applicationHint} disabled={generationPending||!session?.token||!!working||running||!report?.artifact||!available.length} onClick={()=>run('application')}>{working==='application'||running?<><BrainActivity size={18}/> Running…</>:'Run'}</button></span></li>
54
+ </ul>
55
+ {(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>}
56
+ {error&&<p role="alert">{error}</p>}
57
+ </section>;
58
+ }
@@ -0,0 +1,26 @@
1
+ import React,{useEffect,useRef}from'react';
2
+ import{Star,X}from'lucide-react';
3
+ export const STAR_URL='https://github.com/aetna000/atmem-ai-architect';
4
+ const KEY='atmem-architect-star-prompt-v1',SNOOZE=30*24*60*60*1000;
5
+ export function shouldOfferStar(value,now=Date.now()){
6
+ if(!value)return true;
7
+ try{const record=JSON.parse(value);return record.choice!=='opened'&&(!Number.isFinite(record.after)||record.after<=now);}catch{return true;}
8
+ }
9
+ export function StarPrompt({offer=0}){
10
+ const dialog=useRef(null),choice=useRef('later');
11
+ function dismiss(value='later'){
12
+ choice.current=value;
13
+ try{localStorage.setItem(KEY,JSON.stringify({choice:value,after:Date.now()+SNOOZE}));}catch{}
14
+ dialog.current?.close();
15
+ }
16
+ useEffect(()=>{
17
+ if(!offer)return;
18
+ let allowed=true;try{allowed=shouldOfferStar(localStorage.getItem(KEY));}catch{}
19
+ if(allowed&&!dialog.current.open){choice.current='later';dialog.current.showModal();}
20
+ },[offer]);
21
+ return <dialog ref={dialog} className="star-prompt" aria-labelledby="star-prompt-title" onCancel={event=>{event.preventDefault();dismiss();}}>
22
+ <header><h2 id="star-prompt-title"><Star size={22} aria-hidden="true"/> Enjoying Architect?</h2><button type="button" className="icon" aria-label="Close star invitation" onClick={()=>dismiss()}><X size={20}/></button></header>
23
+ <p>If Architect helped, a GitHub star helps others find it.</p>
24
+ <div className="star-prompt-actions"><a className="btn primary" href={STAR_URL} target="_blank" rel="noopener noreferrer" onClick={()=>dismiss('opened')}><Star size={18} aria-hidden="true"/> Star on GitHub</a><button type="button" className="btn" onClick={()=>dismiss()}>Later</button></div>
25
+ </dialog>;
26
+ }
@@ -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()) {
@@ -29,3 +29,13 @@
29
29
  .architecture-work > ul { max-height: 24rem; overflow: auto; padding-left: 1.3rem; }
30
30
  .architecture-work pre { white-space: pre-wrap; overflow-wrap: anywhere; font-size: .8rem; }
31
31
  .architecture-work small { display: block; }
32
+
33
+ .generation-progress { height: 8px; overflow: hidden; border-radius: 999px; background: var(--line, #e1e6ee); margin: 1rem 0 .5rem; }
34
+ .generation-progress > span { display: block; height: 100%; width: 35%; border-radius: inherit; background: var(--action, #f3a2b4); animation: generation-sweep 1.8s ease-in-out infinite; }
35
+ @keyframes generation-sweep { from { transform: translateX(-80%); } to { transform: translateX(266%); } }
36
+ @media (prefers-reduced-motion: reduce) { .generation-progress > span { animation: none; width: 100%; opacity: .65; } }
37
+
38
+ .engineering-progress-count { display: inline-block; font-variant-numeric: tabular-nums; transform-origin: center; }
39
+ .engineering-progress-count.updated { animation: progress-count-pulse 360ms ease-out; }
40
+ @keyframes progress-count-pulse { 0% { opacity: .55; transform: scale(.92); } 45% { opacity: 1; transform: scale(1.1); } 100% { transform: scale(1); } }
41
+ @media (prefers-reduced-motion: reduce) { .engineering-progress-count.updated { animation: none; } }
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,