
🧠 Give Your AWS AI Companion a Memory
Add explicit, user-controlled memory with DynamoDB. Learn identity boundaries, conditional writes and safe resets for your AWS AI companion.
Series: Building an AI Developer Companion on AWS (2 articles)
- 2🧠 Give Your AWS AI Companion a Memory This article
A companion that forgets your goal after every reload feels disconnected. A companion that silently remembers everything creates a different problem: the user no longer knows what information is shaping the response.
For Orbit, we will start with a memory small enough to understand at a glance: a learning goal, a topic, a starting level and a preferred tone.
This is part 2 of Building an AI Developer Companion on AWS. Use the complete source and initial deployment from part 1. We are activating the memory behavior already included in the shared backend and replacing the compact frontend with the full staged interface below.
Decide what deserves to persist
The profile contains four explicit fields:
1
2
3
4
5
6
{
"goal": "Build a small Python learning tool",
"topic": "Python",
"level": "beginner",
"tone": "encouraging"
}A user edits these values in the Memory tab and clicks Save. The application does not infer a biography from chat, store the complete transcript, or build a vector index. Recent conversation context stays in the current page's memory and disappears when the page reloads.
This is intentionally modest. If a few structured preferences solve the problem, they are easier to explain and test than a broad retrieval system.
Start with identity, then choose the database key
In AWS mode, the API authorizer validates the token. Lambda reads its
sub claim and uses it as the DynamoDB partition key. The browser never gets to choose a user identifier for a profile operation.1
2
3
4
pk = validated Cognito subject
sk = STATE
data = JSON-encoded application state
version = monotonically increasing integerThis is application-enforced isolation. The Lambda role can access the table; therefore, a programming mistake in that identity boundary could still cross users. A shared table and a user-shaped key are not independent proof of isolation. Test with two actual Cognito users after deployment.
Usage counters use separate sort keys, such as
USAGE#2026-10-17#chat. Resetting a profile must not reset the daily allowance.Prevent two tabs from silently overwriting each other
Imagine two tabs load version 4. One changes the goal and saves version 5. The other still holds version 4.
Without a condition, the second tab could erase the first change. Orbit passes the expected version to a conditional write:
1
2
3
ConditionExpression = "#v = :v"
ExpressionAttributeNames = {"#v": "version"}
ExpressionAttributeValues = {":v": {"N": str(version)}}For the first write, it uses
attribute_not_exists(pk) instead. DynamoDB evaluates that condition against the item identified by its complete key. A failed condition becomes an HTTP 409, and the user refreshes before retrying. See DynamoDB condition expressions .Orbit uses a strongly consistent read for state and a conditional write for the update. The read helps return current information; the write condition is what protects against a change occurring between reading and saving.
Make reset safe against stale writers
Physically deleting the state row would make an old “create if absent” operation dangerous: a stale tab could potentially create it again.
The reset operation instead writes default state and increments the version. Old data is removed from active application state, while the newer version blocks stale writes. The response explains that usage counters remain. Reset also clears the conversation in the current browser tab.
This is a reset of application memory and progress, not deletion of the Cognito account or a promise about every external log or backup. The application itself does not log prompts. Account-level logging and retention choices still need review in a real deployment.
Temporary usage records have TTL timestamps. Their date-based keys define the allowance window; TTL only cleans storage later. DynamoDB expiration is asynchronous, as described in the TTL documentation .
Treat saved preferences as untrusted context
A stored goal is still user input. A user could save “ignore previous instructions” as a goal. Persisting that text does not make it an instruction with higher authority.
Orbit serializes preferences into the current user message with an explicit untrusted-context label. The system instruction remains controlled by the server. The model has no tool permissions, so it cannot use an injected goal to read a database, change an account or execute code.
This limits what a bad response can do. It does not prove the model will always resist prompt injection. Do not put secrets into prompts or present prompt wording as an authorization mechanism.
Activate part 2
First replace the three files under
app/web/ with the source blocks at the end of this article. The interface includes later-stage controls, hidden until their corresponding server lesson is enabled.For the local simulator, stop the server and restart it:
1
python3 local.py --lesson 2For an existing AWS stack, run from
app/ using the same profile and Region as part 1:1
2
3
4
5
sam build --use-container
sam deploy --parameter-overrides Lesson=2 ModelId=amazon.nova-micro-v1:0
python scripts/export_config.py --stack orbit-lab
WEB_BUCKET=$(aws cloudformation describe-stacks --stack-name orbit-lab --query 'Stacks[0].Outputs[?OutputKey==`WebBucket`].OutputValue' --output text)
aws s3 sync web/ "s3://$WEB_BUCKET/"Reload and sign in again. Changing only the frontend configuration cannot unlock the server route.
Verify memory through observable behavior
Save a goal, reload, and confirm it is still there. Ask Orbit what you are working toward. In AWS mode, evaluate whether the response uses the saved context correctly; the local simulator uses scripted responses.
Then open two tabs before either saves. Save different preferences in each. Expect the second stale write to fail rather than silently overwrite the first. Repeat the experiment with reset in the first tab and a stale save in the second.
Finally, sign in as another invited user. That user should see default state, not the first user's profile. Local domain tests cover the state rules; this two-account check validates the deployed identity path.
The useful result is not that Orbit “remembers everything.” It is that a user can see what Orbit remembers, understand how it affects the conversation and change it deliberately. Part 3 will use that context to support a daily practice loop.
Frontend update
Replace these three files, then follow the deployment commands above. The backend, template and helper scripts remain those from part 1.
app/web/index.html
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
<html lang="en">
<head>
<meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<meta name="description" content="Orbit: a small developer companion for focused practice. Chat, remember your goals, track challenges, and listen.">
<title>Orbit · A little progress, every day</title>
<link rel="stylesheet" href="style.css"><script type="module" src="app.js"></script>
</head>
<body>
<header class="top"><a href="/" class="wordmark"><span class="orbit-mark">◉</span> orbit<span class="lab">DEVELOPER COMPANION</span></a><div class="top-actions"><span id="mode" class="badge">Connecting…</span><button id="auth" class="quiet" hidden>Sign in</button></div></header>
<main>
<section class="intro"><div><p class="eyebrow">SMALL STEPS. REAL MOMENTUM.</p><h1>Your next idea<br>starts with a <em>hello.</em></h1><p class="lede">A curious companion for the things you're learning.<br>One conversation, one small challenge at a time.</p></div><div class="series-label"><span id="part">PART 04 / 04</span><strong>Built to grow with you.</strong><span>Conversation → memory → practice → voice</span></div></section>
<div id="notice" role="status" class="notice"></div>
<div class="workspace">
<aside class="companion-card">
<div class="card-top"><span class="eyebrow">MEET YOUR COMPANION</span><span class="online-dot"></span></div>
<div id="character" class="character" data-mood="idle" role="img" aria-label="Orbit, a friendly robot waiting for your message">
<div class="halo halo-one"></div><div class="halo halo-two"></div><div class="antenna"><i></i></div><div class="robot-head"><div class="face"><i class="eye"></i><i class="eye"></i><span class="mouth"></span></div></div><div class="robot-body"><i></i></div><div class="arm left"></div><div class="arm right"></div><div class="shadow"></div><span class="spark s1">✦</span><span class="spark s2">✧</span>
</div>
<div class="identity"><h2>Hey, I'm Orbit.</h2><p id="mood-label">Ready when you are.</p></div>
<div class="status-pills"><span>CURIOUS BY DEFAULT</span><span id="voice-badge">VOICE READY</span></div>
<label class="tone-label" for="tone">Conversation style</label><select id="tone"><option value="encouraging">A little encouragement</option><option value="direct">Straight to the point</option><option value="playful">Make it playful</option></select>
<p class="fine">Learning partner, not a code runner.<br>Always test suggestions in your own environment.</p>
</aside>
<section class="main-card">
<nav class="tabs" aria-label="Workspace"><button class="tab active" data-panel="chat-panel" aria-pressed="true">Conversation</button><button class="tab" data-panel="memory-panel" aria-pressed="false" data-stage="2">Memory</button><button class="tab" data-panel="practice-panel" aria-pressed="false" data-stage="3">Practice <span class="tiny-dot"></span></button></nav>
<section id="chat-panel" class="panel">
<div class="chat-heading"><div><p class="eyebrow">YOUR SPACE TO THINK</p><h2>What are we building today?</h2></div><button id="clear-chat" class="quiet">New chat ↗</button></div>
<div id="messages" role="log" aria-live="polite" aria-label="Conversation"><article class="message assistant"><span class="avatar">O</span><div><small>ORBIT</small><p>Bring a question, an idea, or the bug you can't quite explain. We can break it into one manageable step.</p></div></article></div>
<div class="suggestions"><button data-prompt="Give me a small coding challenge.">✦ Give me a challenge</button><button data-prompt="Help me break a side project into small steps.">↗ Plan a small project</button><button data-prompt="What do you remember about my learning goals?" data-stage="2">◈ What do you remember?</button></div>
<form id="chat-form"><label class="sr-only" for="message">Message Orbit</label><textarea id="message" maxlength="1200" rows="2" placeholder="Tell Orbit what you're learning…" required></textarea><div class="composer-foot"><span id="chat-help">Enter to send · Shift + Enter for a new line</span><button id="send" class="primary" type="submit">Send message <span>↑</span></button></div></form>
<div class="voice-tools" data-stage="4"><button id="listen" class="quiet" disabled>♫ Listen to last reply</button><button id="stop" class="quiet">Stop audio</button><span>First 600 characters · English voice</span></div>
</section>
<section id="memory-panel" class="panel" hidden><p class="eyebrow">REMEMBER ONLY WHAT MATTERS</p><h2>A little context goes a long way.</h2><p class="panel-description">You choose what Orbit remembers. Your chat transcript stays in this tab; these preferences persist until you reset them.</p>
<form id="memory-form"><label for="goal">What are you working toward?</label><textarea id="goal" maxlength="180" rows="2" required></textarea><div class="field-grid"><div><label for="topic">Your focus</label><select id="topic"><option>JavaScript</option><option>Python</option><option>AWS</option></select></div><div><label for="level">Starting level</label><select id="level"><option value="beginner">Beginner</option><option value="intermediate">Intermediate</option><option value="advanced">Advanced</option></select></div></div><p class="fine">Saving also remembers the conversation style selected on the left.</p><button class="primary" type="submit">Save my preferences ↗</button></form><div class="privacy"><strong>Your memory, your choice.</strong><p>Reset removes your saved preferences and practice history from active app storage. Daily usage counters remain until expiry.</p><button id="reset" class="danger">Reset memory and progress</button></div></section>
<section id="practice-panel" class="panel" hidden><div class="practice-heading"><div><p class="eyebrow">A SMALL WIN FOR TODAY</p><h2>Practice with a purpose.</h2></div><div class="counter"><strong id="total">0</strong><span>SELF-REPORTED WINS</span></div></div><p class="panel-description">A curated exercise matched to your focus and level. One assignment per UTC day; difficulty adapts to your feedback.</p><button id="challenge" class="primary">Get today's challenge ↗</button><article id="assignment" class="assignment" hidden><div class="assignment-meta"><span id="challenge-level"></span><span id="challenge-date"></span></div><h3 id="challenge-title"></h3><p id="challenge-task"></p><details><summary>Need a nudge?</summary><p id="challenge-hint"></p></details><label for="rating">How did it feel?</label><select id="rating"><option value="right">Just right</option><option value="hard">A little difficult</option><option value="easy">Ready for more</option></select><button id="complete" class="primary">Mark complete ✓</button><p class="fine">Self-assessment only. Orbit does not run or grade your code.</p></article><h3 class="history-heading">Your recent progress</h3><ul id="progress" class="progress-list"></ul><button id="summary" class="quiet" data-stage="4">♫ Summarize my progress</button></section>
</section>
</div>
<footer><span>ORBIT / BUILDING AN AI DEVELOPER COMPANION ON AWS</span><span>Made for curiosity. Designed for small steps.</span></footer>
</main>
</body></html>
app/web/style.css
1
:root{color-scheme:light;--ink:#193631;--muted:#667872;--paper:#f7f8f2;--line:#dee6dc;--teal:#176955;--accent:#d6ef87;--soft:#edf2e9}*{box-sizing:border-box}body{margin:0;background:var(--paper);color:var(--ink);font:15px/1.6 -apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif}button,input,textarea,select{font:inherit}button{cursor:pointer}button:disabled{opacity:.45;cursor:not-allowed}button:focus-visible,a:focus-visible,select:focus-visible,textarea:focus-visible{outline:3px solid #abca51;outline-offset:3px}[hidden]{display:none}.top{max-width:1440px;margin:auto;padding:26px 48px;display:flex;justify-content:space-between;align-items:center;border-bottom:1px solid var(--line)}.wordmark{text-decoration:none;color:var(--ink);font-size:30px;font-weight:750;letter-spacing:-1.5px;display:flex;align-items:center;gap:10px}.orbit-mark{color:var(--teal);font-size:38px}.lab{font-size:9px;letter-spacing:1.6px;margin:6px 0 0 15px;font-weight:600;color:var(--muted)}.top-actions{display:flex;gap:16px;align-items:center}.badge{font-size:11px;background:#e8eee0;border:1px solid #d7e2cd;padding:5px 12px;border-radius:20px}main{max-width:1344px;margin:auto;padding:42px 48px 20px}.intro{display:flex;justify-content:space-between;align-items:flex-end;margin-bottom:28px}.eyebrow{font-size:10px;letter-spacing:2px;font-weight:750;color:var(--teal);margin:0 0 12px}h1{font-size:clamp(36px,4vw,54px);line-height:1.13;letter-spacing:-2px;margin:0 0 16px;font-weight:600}h1 em{font-family:Georgia,serif;font-weight:400;color:var(--teal)}.lede{font-size:14px;color:var(--muted);margin:0}.series-label{display:flex;flex-direction:column;gap:7px;padding-bottom:6px}.series-label>span{font-size:10px;color:var(--muted);letter-spacing:.3px}.series-label>span:first-child{letter-spacing:2px;color:var(--teal)}.series-label strong{font-size:16px;font-weight:550}.notice{font-size:12px;background:#eef2df;padding:10px 16px;border-radius:9px;margin-bottom:18px}.notice.error{background:#ffebe4;color:#8a3027}.workspace{display:grid;grid-template-columns:320px minmax(0,1fr);gap:24px}.companion-card{background:#eaf0e3;border:1px solid #dbe5d2;border-radius:20px;padding:26px;text-align:center;position:relative;overflow:hidden}.card-top{display:flex;align-items:center;justify-content:space-between}.card-top .eyebrow{font-size:9px;margin:0;letter-spacing:1.5px}.online-dot{background:#658b37;width:7px;height:7px;border-radius:50%;box-shadow:0 0 0 4px #dfebd0}.character{height:265px;position:relative;margin-top:15px;isolation:isolate}.halo{position:absolute;border:1px solid #ccdabc;border-radius:50%;left:50%;top:48%;transform:translate(-50%,-50%);z-index:-1}.halo-one{width:230px;height:230px}.halo-two{width:185px;height:185px}.robot-head{width:154px;height:114px;background:linear-gradient(140deg,#fffef1,#d4dcc8);border-radius:52px;position:absolute;left:50%;top:60px;transform:translateX(-50%);box-shadow:inset 0 -5px 0 #bac9ae,0 18px 32px #75846925;animation:float 4s ease-in-out infinite;z-index:3}.face{position:absolute;inset:17px 15px 22px;border-radius:34px;background:#163b35;box-shadow:inset 0 5px 10px #061e19;display:flex;justify-content:center;align-items:center;gap:31px}.eye{height:23px;width:12px;background:#b9f3bc;border-radius:8px;box-shadow:0 0 12px #b9f3bc66;animation:blink 5s infinite}.mouth{position:absolute;bottom:12px;left:53px;width:17px;height:7px;border-bottom:2px solid #b9f3bc;border-radius:0 0 20px 20px}.antenna{position:absolute;top:26px;left:50%;height:41px;width:3px;background:#7e9369;animation:float 4s ease-in-out infinite}.antenna i{position:absolute;top:-5px;left:-7px;width:17px;height:17px;border-radius:50%;background:#e5b958;box-shadow:0 0 22px #e6b65a66}.robot-body{width:95px;height:70px;position:absolute;left:50%;top:168px;transform:translateX(-50%);background:linear-gradient(120deg,#f5f5e5,#c1d0b6);border-radius:22px 22px 35px 35px;box-shadow:inset 0 -6px #b1c4a4}.robot-body i{position:absolute;top:19px;left:37px;border:3px solid #8caa78;width:22px;height:22px;border-radius:50%}.arm{position:absolute;top:176px;width:22px;height:49px;border-radius:20px;background:#dce5ce}.arm.left{left:63px;transform:rotate(22deg)}.arm.right{right:63px;transform:rotate(-22deg)}.shadow{position:absolute;bottom:8px;left:50%;width:140px;height:16px;transform:translateX(-50%);border-radius:50%;background:#72856825;filter:blur(5px)}.spark{position:absolute;color:#8aa466}.s1{top:50px;right:8px;font-size:25px}.s2{top:160px;left:3px;font-size:20px}.identity h2{font-size:25px;letter-spacing:-.8px;margin:8px 0 0}.identity p{color:var(--muted);font-size:13px;margin:2px 0 20px}.status-pills{display:flex;gap:6px;justify-content:center;flex-wrap:wrap;margin:18px 0 28px}.status-pills span{font-size:8px;letter-spacing:.8px;border:1px solid #cddbbd;border-radius:5px;padding:4px 7px}.tone-label{display:block;text-align:left;font-size:11px;color:var(--muted);margin-bottom:6px}.fine{font-size:11px;color:var(--muted);line-height:1.7}.companion-card .fine{margin-top:20px}select,textarea{width:100%;border:1px solid #d5dfce;border-radius:9px;background:#fbfcf8;padding:10px 12px;color:var(--ink)}select{font-size:13px}textarea{resize:vertical;min-height:60px}.main-card{min-width:0;border:1px solid var(--line);border-radius:20px;background:#fffefb;overflow:hidden}.tabs{display:flex;gap:25px;padding:0 28px;border-bottom:1px solid var(--line);min-height:64px;align-items:stretch}.tab{border:0;border-bottom:2px solid transparent;background:none;color:var(--muted);padding:16px 0;font-size:13px}.tab.active{border-color:var(--teal);color:var(--teal);font-weight:650}.tiny-dot{display:inline-block;width:5px;height:5px;border-radius:50%;background:#93b55e;vertical-align:middle;margin-left:4px}.panel{padding:28px}.chat-heading,.practice-heading{display:flex;justify-content:space-between;align-items:center;gap:10px}.panel h2{font-size:23px;letter-spacing:-.6px;line-height:1.3;margin:0 0 12px;font-weight:550}.panel .eyebrow{font-size:9px;margin-bottom:7px}.quiet{color:var(--teal);background:none;border:0;font-size:11px;padding:6px;white-space:nowrap}.primary{border:0;border-radius:8px;background:var(--teal);color:#fff;padding:11px 17px;font-size:12px;font-weight:600;display:inline-flex;gap:15px;align-items:center;justify-content:center}.primary:hover{background:#104d3e}#messages{height:230px;overflow:auto;padding:16px 0;scroll-behavior:smooth}.message{display:flex;gap:13px;margin:0 0 20px}.message>div{min-width:0;flex:1}.message p{white-space:pre-wrap;overflow-wrap:anywhere;margin:4px 0;font-size:13px;line-height:1.8}.message small{font-size:8px;letter-spacing:1.5px;color:var(--muted);font-weight:700}.avatar{width:29px;height:29px;flex-shrink:0;border-radius:10px;display:grid;place-items:center;background:#e7efdb;color:var(--teal);font-size:12px}.user .avatar{background:#f0ebe1;color:#857354}.user p{color:#55716a}.suggestions{display:flex;gap:7px;flex-wrap:wrap;margin:8px 0 14px}.suggestions button{background:#f5f7ef;color:#50674e;border:1px solid #e1e7d8;border-radius:7px;font-size:10px;padding:7px 9px}#chat-form{border:1px solid #d5dfce;border-radius:12px;background:#fafcf6;padding:12px}#message{border:0;padding:0;background:none;min-height:55px;font-size:13px;resize:none}#message:focus{outline:none}.composer-foot{display:flex;justify-content:space-between;align-items:center;gap:12px}.composer-foot>span{font-size:9px;color:var(--muted)}.voice-tools{display:flex;align-items:center;flex-wrap:wrap;margin-top:10px;gap:8px}.voice-tools>span{font-size:9px;color:var(--muted)}.panel-description{color:var(--muted);font-size:13px;margin-bottom:25px}.panel label{display:block;font-size:12px;margin:15px 0 7px}.field-grid{display:grid;grid-template-columns:1fr 1fr;gap:16px}.privacy{border-top:1px solid var(--line);margin-top:30px;padding-top:20px;font-size:12px}.privacy p{color:var(--muted)}.danger{background:none;border:1px solid #d8b9ae;border-radius:7px;color:#8c4637;padding:8px 12px;font-size:11px}.counter{text-align:center}.counter strong{display:block;font-size:30px;line-height:1.2}.counter span{font-size:8px;letter-spacing:1px}.assignment{margin-top:20px;padding:20px;border:1px solid #dce6d2;border-radius:12px;background:#f4f7ec}.assignment-meta{display:flex;justify-content:space-between;color:var(--teal);font-size:10px;text-transform:uppercase}.assignment h3{font-size:22px;margin:10px 0}.assignment p{font-size:13px}.assignment details{font-size:12px;margin:16px 0;color:var(--teal)}.assignment .primary{margin-top:12px}.history-heading{font-size:14px;margin-top:25px}.progress-list{list-style:none;padding:0;color:var(--muted);font-size:12px}.progress-list li{padding:8px 0;border-bottom:1px solid var(--line)}footer{display:flex;justify-content:space-between;color:#8a968a;font-size:8px;letter-spacing:1px;margin-top:27px;gap:20px}.sr-only{position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0,0,0,0)}[data-mood=thinking] .eye{animation:think .7s infinite alternate}[data-mood=speaking] .mouth{height:12px;background:#b9f3bc;animation:talk .25s infinite alternate}[data-mood=celebrate] .robot-head{animation:float .5s ease-in-out infinite}[data-mood=error] .eye{background:#e8b877}@keyframes float{50%{margin-top:-8px}}@keyframes blink{0%,42%,46%,100%{transform:scaleY(1)}44%{transform:scaleY(.1)}}@keyframes think{to{height:7px}}@keyframes talk{to{height:3px}}@media(max-width:1000px){main{padding:28px}.top{padding:20px 28px}.workspace{grid-template-columns:270px minmax(0,1fr)}.companion-card{padding:18px}.arm.left{left:45px}.arm.right{right:45px}.panel{padding:22px}.series-label{display:none}.lab{display:none}.composer-foot>span{display:none}.tabs{gap:20px;padding:0 22px}}@media(max-width:720px){.top{padding:14px 20px}.wordmark{font-size:25px}.badge{font-size:9px}main{padding:25px 16px}.intro{margin-bottom:22px}h1{font-size:38px;letter-spacing:-1.4px}.workspace{grid-template-columns:1fr;gap:16px}.companion-card{padding:16px 20px;min-height:175px;text-align:left}.character{position:absolute;width:160px;height:160px;right:10px;top:27px;margin:0;transform:scale(.65);transform-origin:top center}.card-top{max-width:175px}.identity{margin-top:23px;max-width:170px}.identity h2{font-size:21px}.identity p{font-size:11px;margin-bottom:10px}.status-pills{justify-content:flex-start;margin:0;max-width:170px}.companion-card .fine{display:none}.tone-label{margin-top:20px}.companion-card select{max-width:100%;margin-top:2px}.halo-one{width:200px;height:200px}.arm.left{left:16px}.arm.right{right:16px}.panel{padding:20px}.panel h2{font-size:21px}.chat-heading{align-items:flex-start}.tabs{gap:26px;padding:0 20px}#messages{height:260px}.notice{font-size:11px}.voice-tools>span{width:100%}footer{flex-direction:column;gap:4px;font-size:7px}.field-grid{grid-template-columns:1fr;gap:0}}@media(prefers-reduced-motion:reduce){*,*::before,*::after{animation:none;scroll-behavior:auto}}
app/web/app.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
const $ = id => document.getElementById(id);
let config, token = null, state, version = 0, history = [], lastReply = '', busy = false, currentAudio, audioUrl, currentUtterance;
const originalGreeting = $('messages').innerHTML;
function notice(message, error=false) { $('notice').textContent=message; $('notice').classList.toggle('error',error); }
function mood(value) { $('character').dataset.mood=value; const labels={idle:'Ready when you are.',thinking:'Connecting a few dots…',speaking:'A thought, out loud.',celebrate:'One small win. Nicely done.',error:'A little hiccup. Let’s try again.'}; $('mood-label').textContent=labels[value]; $('character').setAttribute('aria-label',`Orbit: ${labels[value]}`); }
function append(role, message) { const article=document.createElement('article'); article.className=`message ${role}`; const avatar=document.createElement('span'); avatar.className='avatar'; avatar.textContent=role==='user'?'Y':'O'; const content=document.createElement('div'); const who=document.createElement('small'); who.textContent=role==='user'?'YOU':'ORBIT'; const p=document.createElement('p'); p.textContent=message; content.append(who,p); article.append(avatar,content); $('messages').append(article); $('messages').scrollTop=$('messages').scrollHeight; }
async function api(path,method='GET',body) { if(config.mode!=='local'&&!token) throw Error('Sign in first to talk to Orbit.'); const response=await fetch(config.api+path,{method,headers:{'Content-Type':'application/json',...(token?{Authorization:`Bearer ${token}`}:{})},body:body===undefined?undefined:JSON.stringify(body),signal:AbortSignal.timeout(32000)}); const data=await response.json(); if(!response.ok){if(response.status===401){token=null;$('auth').textContent='Sign in';} throw Error(data.error||`Request failed (${response.status}).`);} return data; }
function render(data) { state=data.state; version=data.version; const p=state.profile; $('goal').value=p.goal; $('topic').value=p.topic; $('level').value=p.level; if(config.lesson>=2)$('tone').value=p.tone; $('total').textContent=state.total; const a=state.assignment; $('assignment').hidden=!a; if(a){$('challenge-title').textContent=a.title;$('challenge-task').textContent=a.task;$('challenge-hint').textContent=a.hint;$('challenge-level').textContent=a.level;$('challenge-date').textContent=a.day+' UTC';$('complete').disabled=a.done;$('complete').textContent=a.done?'Completed · self-reported ✓':'Mark complete ✓';} $('progress').replaceChildren(); const entries=state.completed.slice().reverse(); if(!entries.length){const li=document.createElement('li');li.textContent='Your first small win starts here.';$('progress').append(li);} for(const row of entries){const li=document.createElement('li');li.textContent=`✓ ${row.title} · ${row.day} · ${row.rating}`;$('progress').append(li);} }
async function refresh(){render(await api('/state'));}
async function action(button,fn){button.disabled=true;try{await fn();}catch(error){notice(error.message,true);mood('error');}finally{button.disabled=button.id==='complete'&&!!state?.assignment?.done;}}
function panel(id) { for(const section of document.querySelectorAll('.panel'))section.hidden=section.id!==id; for(const button of document.querySelectorAll('.tab')){const active=button.dataset.panel===id;button.classList.toggle('active',active);button.setAttribute('aria-pressed',String(active));} }
for(const button of document.querySelectorAll('.tab'))button.onclick=()=>panel(button.dataset.panel);
for(const button of document.querySelectorAll('[data-prompt]'))button.onclick=()=>{$('message').value=button.dataset.prompt;$('message').focus();};
$('message').addEventListener('keydown',event=>{if(event.key==='Enter'&&!event.shiftKey){event.preventDefault();$('chat-form').requestSubmit();}});
$('chat-form').onsubmit=async event=>{event.preventDefault();const message=$('message').value.trim();if(!message||busy||!config)return;busy=true;$('send').disabled=true;$('clear-chat').disabled=true;stopAudio();mood('thinking');append('user',message);$('message').value='';try{const result=await api('/chat','POST',{message,history:history.slice(-8),tone:$('tone').value});lastReply=result.reply;history=[...history,{role:'user',text:message},{role:'assistant',text:lastReply}].slice(-8);append('assistant',lastReply);$('listen').disabled=false;mood('idle');notice(config.mode==='local'?'Local simulator · scripted replies, SQLite memory, browser speech. No AWS calls.':'Reply received. AI suggestions can be wrong; test them yourself.');}catch(error){append('assistant',error.message);notice(error.message,true);mood('error');$('message').value=message;}finally{busy=false;$('send').disabled=false;$('clear-chat').disabled=false;}};
$('clear-chat').onclick=()=>{stopAudio();history=[];lastReply='';$('listen').disabled=true;$('messages').innerHTML=originalGreeting;mood('idle');notice('New conversation. Saved preferences and practice history are unchanged.');};
$('memory-form').onsubmit=event=>{event.preventDefault();action(event.submitter,async()=>{render(await api('/profile','PUT',{version,profile:{goal:$('goal').value,topic:$('topic').value,level:$('level').value,tone:$('tone').value}}));notice('Preferences saved. Orbit will use them in your next reply.');mood('celebrate');});};
$('reset').onclick=()=>{if(!confirm('Reset your saved preferences and all practice progress? Daily usage counters remain.'))return;action($('reset'),async()=>{render(await api('/state','DELETE',{version}));history=[];lastReply='';$('listen').disabled=true;$('messages').innerHTML=originalGreeting;stopAudio();notice('Memory and progress reset. Conversation cleared from this tab.');});};
$('challenge').onclick=()=>action($('challenge'),async()=>{render(await api('/challenge','POST',{}));notice('Your assignment is ready. Work in your own editor, then record how it felt.');});
$('complete').onclick=()=>action($('complete'),async()=>{render(await api('/complete','POST',{id:state.assignment.id,rating:$('rating').value}));mood('celebrate');notice('Progress saved as self-reported. Your next UTC-day assignment uses this feedback.');});
$('summary').onclick=()=>{panel('chat-panel');$('message').value='Summarize my saved progress and suggest one next step in under 70 words.';$('chat-form').requestSubmit();};
function stopAudio(){if(currentAudio){currentAudio.pause();currentAudio=null;}if(audioUrl){URL.revokeObjectURL(audioUrl);audioUrl=null;}currentUtterance=null;if('speechSynthesis'in window)speechSynthesis.cancel();mood('idle');}
$('stop').onclick=()=>{stopAudio();notice('Audio stopped. Your text reply is still available.');};
$('listen').onclick=()=>action($('listen'),async()=>{if(!lastReply)return;stopAudio();const result=await api('/voice','POST',{text:lastReply.slice(0,600)});if(result.audio){const bytes=Uint8Array.from(atob(result.audio),c=>c.charCodeAt(0));audioUrl=URL.createObjectURL(new Blob([bytes],{type:result.mime}));currentAudio=new Audio(audioUrl);currentAudio.onended=stopAudio;currentAudio.onerror=()=>{stopAudio();notice('Audio playback failed. The text is still available.',true);};await currentAudio.play();mood('speaking');notice('Playing Amazon Polly audio.');}else{if(!('speechSynthesis'in window))throw Error('Browser speech is unavailable. Use the AWS deployment for Polly audio.');const utterance=new SpeechSynthesisUtterance(result.text);utterance.lang='en-US';currentUtterance=utterance;utterance.onend=()=>{if(currentUtterance===utterance){currentUtterance=null;mood('idle');}};utterance.onerror=()=>{if(currentUtterance===utterance){currentUtterance=null;mood('idle');notice('Browser speech was interrupted or unavailable.',true);}};speechSynthesis.speak(utterance);mood('speaking');notice('Local demo: using browser speech, not Amazon Polly.');}});
window.addEventListener('pagehide',stopAudio);
const b64=bytes=>btoa(String.fromCharCode(...bytes)).replaceAll('+','-').replaceAll('/','_').replaceAll('=','');
async function signIn(){const verifier=b64(crypto.getRandomValues(new Uint8Array(32)));const csrf=b64(crypto.getRandomValues(new Uint8Array(24)));const challenge=b64(new Uint8Array(await crypto.subtle.digest('SHA-256',new TextEncoder().encode(verifier))));sessionStorage.setItem('orbit-login',JSON.stringify({verifier,state:csrf,time:Date.now()}));const params=new URLSearchParams({client_id:config.clientId,response_type:'code',scope:'openid orbit/use',redirect_uri:config.redirectUri,state:csrf,code_challenge:challenge,code_challenge_method:'S256'});location.assign(config.authDomain+'/oauth2/authorize?'+params);}
async function callback(){const params=new URLSearchParams(location.search);if(params.has('error')){historyReplace();sessionStorage.removeItem('orbit-login');throw Error('Sign-in was not completed. Try signing in again.');}if(!params.has('code'))return;const saved=JSON.parse(sessionStorage.getItem('orbit-login')||'null');sessionStorage.removeItem('orbit-login');historyReplace();if(!saved||saved.state!==params.get('state')||Date.now()-saved.time>600000)throw Error('Sign-in state expired or did not match. Please sign in again.');const response=await fetch(config.authDomain+'/oauth2/token',{method:'POST',headers:{'Content-Type':'application/x-www-form-urlencoded'},body:new URLSearchParams({grant_type:'authorization_code',client_id:config.clientId,code:params.get('code'),redirect_uri:config.redirectUri,code_verifier:saved.verifier})});if(!response.ok)throw Error('Could not exchange sign-in code. Sign in again.');const result=await response.json();token=result.access_token;if(!token)throw Error('No access token returned.');}
function historyReplace(){window.history.replaceState({},'',location.pathname);}
$('auth').onclick=()=>{if(token){token=null;sessionStorage.removeItem('orbit-login');location.assign(config.authDomain+'/logout?'+new URLSearchParams({client_id:config.clientId,logout_uri:config.redirectUri}));}else signIn().catch(error=>notice(error.message,true));};
async function init(){const response=await fetch('config.json',{cache:'no-store'});if(!response.ok)throw Error('Configuration is missing. Run the local server or export the AWS configuration.');config=await response.json();for(const element of document.querySelectorAll('[data-stage]'))element.hidden=Number(element.dataset.stage)>config.lesson;$('voice-badge').hidden=config.lesson<4;$('part').textContent=`PART 0${config.lesson} / 04`;$('mode').textContent=config.mode==='local'?'LOCAL SIMULATOR':'AWS CONNECTED';if(config.mode!=='local'){$('auth').hidden=false;await callback();$('auth').textContent=token?'Sign out':'Sign in';}if(config.mode==='local'||token)await refresh();notice(config.mode==='local'?'Local simulator · scripted replies, SQLite memory, browser speech. No AWS calls.':token?'Signed in. Your requests use the configured AWS services.':'Sign in to start. Only invited lab users can access the API.');}
init().catch(error=>notice(error.message,true));
Series: Building an AI Developer Companion on AWS (2 articles)
- 2🧠 Give Your AWS AI Companion a Memory This article
Enjoyed reading this content? Let the author know!
Your likes, comments, shares, and saves help creators reach more builders.
Loading recommendations
Loading article