DEV Community

Arya Ajgaonkar
Arya Ajgaonkar

Posted on

FriendOS — A private AI memory for someone I care about

This is a submission for the Hacktoberfest Weekend Challenge: Build for a Friend

What I Built

I built FriendOS for a friend who constantly has important things scattered across messages, notes, and random conversations.

The idea is simple: give the app something my friend said, and it turns the chaos into useful memories — tasks, dates, commitments, and things worth remembering. You can then ask questions like “When is my interview?” or “What did I say I needed to finish?”

The important part is that this isn't another AI app that sends someone's personal life to a server they don't control.

FriendOS is built around open-source AI and local inference. The model can run on the user's own machine, which means their memories can stay on their computer. There's no required API key, no per-request bill, and no dependency on a proprietary AI provider.

That's why open innovation mattered for this project.

A friend's messages and personal memories are exactly the kind of data I don't want to blindly upload somewhere. With an open model, I can choose the model, run it locally, swap it out, inspect how the system works, and eventually fine-tune or customize it for the person using it.

I deliberately kept the project small. This weekend's challenge was “Build for a Friend,” so I wanted to build something for one real person rather than another generic AI demo.

The goal wasn't to build the smartest memory system.

It was to build something my friend could actually use.

And then hand it to them.

Code

app.py
import json
import urllib.request
from http.server import BaseHTTPRequestHandler, HTTPServer
from pathlib import Path

PORT = 8000
OLLAMA_URL = "http://localhost:11434/api/generate"
MODEL = "qwen2.5:3b"

BASE_DIR = Path(file).parent
MEMORY_FILE = BASE_DIR / "memories.json"
HTML_FILE = BASE_DIR / "index.html"

def load_memories():
try:
return json.loads(MEMORY_FILE.read_text())
except Exception:
return []

def save_memories(memories):
MEMORY_FILE.write_text(json.dumps(memories, indent=2))

def ask_ollama(prompt):
payload = json.dumps({
"model": MODEL,
"prompt": prompt,
"stream": False
}).encode()

request = urllib.request.Request(
    OLLAMA_URL,
    data=payload,
    headers={"Content-Type": "application/json"}
)

with urllib.request.urlopen(request, timeout=120) as response:
    data = json.loads(response.read().decode())

return data["response"]
Enter fullscreen mode Exit fullscreen mode

def extract_memories(text):
prompt = f"""
You are FriendMemory, a private personal memory assistant.

Analyze the following message from a person's friend.

Extract only information that would actually be useful to remember later.

Return ONLY valid JSON in this exact format:

[
{{
"type": "task",
"title": "short title",
"detail": "useful detail"
}}
]

Allowed types:

  • task
  • date
  • person
  • preference
  • commitment
  • important

Message:

{text}
"""

response = ask_ollama(prompt)

# Handle models that wrap JSON in markdown.
response = response.strip()

if response.startswith("
Enter fullscreen mode Exit fullscreen mode
        response = response.replace("```

json", "").replace("

Enter fullscreen mode Exit fullscreen mode


", "").strip()

try:
    result = json.loads(response)

    if not isinstance(result, list):
        return []

    return result

except Exception:
    return []
Enter fullscreen mode Exit fullscreen mode

def answer_question(question, memories):
memory_text = "\n".join(
f"- {m.get('type', 'memory')}: {m.get('title', '')} — {m.get('detail', '')}"
for m in memories
)

prompt = f"""
Enter fullscreen mode Exit fullscreen mode

You are a private memory assistant.

Answer the user's question using ONLY the memories below.

If the answer isn't present, say:
"I don't have that in my memories."

Keep the answer short and natural.

Memories:
{memory_text}

Question:
{question}
"""

return ask_ollama(prompt).strip()
Enter fullscreen mode Exit fullscreen mode

class Handler(BaseHTTPRequestHandler):

def send_json(self, data, status=200):
    body = json.dumps(data).encode()

    self.send_response(status)
    self.send_header("Content-Type", "application/json")
    self.send_header("Access-Control-Allow-Origin", "*")
    self.send_header("Content-Length", str(len(body)))
    self.end_headers()
    self.wfile.write(body)

def do_GET(self):
    if self.path == "/":
        body = HTML_FILE.read_bytes()

        self.send_response(200)
        self.send_header("Content-Type", "text/html")
        self.send_header("Content-Length", str(len(body)))
        self.end_headers()
        self.wfile.write(body)

    elif self.path == "/memories":
        self.send_json(load_memories())

    else:
        self.send_error(404)

def do_POST(self):
    length = int(self.headers.get("Content-Length", 0))
    body = self.rfile.read(length)

    try:
        data = json.loads(body.decode())
    except Exception:
        self.send_json({"error": "Invalid JSON"}, 400)
        return

    if self.path == "/remember":
        text = data.get("text", "").strip()

        if not text:
            self.send_json({"error": "No text provided"}, 400)
            return

        try:
            extracted = extract_memories(text)
        except Exception as e:
            self.send_json({
                "error": f"Could not contact Ollama: {e}"
            }, 500)
            return

        memories = load_memories()

        for memory in extracted:
            memory["source"] = text
            memories.append(memory)

        save_memories(memories)

        self.send_json({
            "added": extracted,
            "all": memories
        })

    elif self.path == "/ask":
        question = data.get("question", "").strip()

        if not question:
            self.send_json({"error": "No question provided"}, 400)
            return

        try:
            answer = answer_question(question, load_memories())
        except Exception as e:
            self.send_json({
                "error": f"Could not contact Ollama: {e}"
            }, 500)
            return

        self.send_json({"answer": answer})

    elif self.path == "/clear":
        save_memories([])
        self.send_json({"success": True})

    else:
        self.send_error(404)
Enter fullscreen mode Exit fullscreen mode

print(f"FriendMemory running at http://localhost:{PORT}")
HTTPServer(("localhost", PORT), Handler).serve_forever()

index.html
<!DOCTYPE html>



FriendMemory
  • { box-sizing: border-box; }

body {
margin: 0;
font-family: Inter, system-ui, sans-serif;
background: #0b1020;
color: #f8fafc;
}

.container {
max-width: 900px;
margin: auto;
padding: 50px 20px;
}

.hero {
margin-bottom: 35px;
}

.badge {
display: inline-block;
background: #172554;
color: #93c5fd;
padding: 7px 12px;
border-radius: 999px;
font-size: 13px;
margin-bottom: 15px;
}

h1 {
font-size: 48px;
margin: 0 0 10px;
}

.subtitle {
color: #94a3b8;
font-size: 18px;
line-height: 1.6;
}

.card {
background: #111827;
border: 1px solid #1f2937;
border-radius: 18px;
padding: 22px;
margin-bottom: 20px;
}

textarea,
input {
width: 100%;
background: #020617;
color: white;
border: 1px solid #334155;
border-radius: 12px;
padding: 15px;
font-size: 15px;
outline: none;
}

textarea {
min-height: 150px;
resize: vertical;
}

textarea:focus,
input:focus {
border-color: #6366f1;
}

button {
border: 0;
border-radius: 10px;
padding: 12px 18px;
background: #6366f1;
color: white;
font-weight: 700;
cursor: pointer;
margin-top: 12px;
}

button:hover {
background: #4f46e5;
}

.secondary {
background: #1e293b;
}

.memories {
display: grid;
gap: 12px;
margin-top: 18px;
}

.memory {
background: #020617;
border: 1px solid #1e293b;
padding: 16px;
border-radius: 12px;
}

.memory-type {
color: #818cf8;
text-transform: uppercase;
font-size: 11px;
font-weight: bold;
letter-spacing: 1px;
}

.memory-title {
font-size: 17px;
font-weight: 700;
margin-top: 5px;
}

.memory-detail {
color: #94a3b8;
margin-top: 5px;
}

.answer {
margin-top: 15px;
padding: 15px;
background: #172554;
border-radius: 12px;
color: #bfdbfe;
}

.status {
color: #94a3b8;
margin-top: 10px;
min-height: 20px;
}

.privacy {
text-align: center;
color: #64748b;
margin-top: 30px;
font-size: 13px;
}

    LOCAL AI · OPEN WEIGHT · PRIVATE

    <h1>🧠 FriendMemory</h1>


        A tiny AI memory assistant for someone you care about.
        Turn messy messages into memories you can actually find later.




    <h2>💬 Remember something</h2>



    Remember this





    <h2>🔎 Ask your memories</h2>



    Ask





    <h2>💭 Your memories</h2>




        Clear everything




    🔒 Your memories stay on your computer.
    AI inference happens locally through Ollama.
Enter fullscreen mode Exit fullscreen mode

async function remember() {

const text = document.getElementById("message").value;
const status = document.getElementById("status");

if (!text.trim()) return;

status.textContent = "Thinking...";

try {

    const response = await fetch("/remember", {
        method: "POST",
        headers: {
            "Content-Type": "application/json"
        },
        body: JSON.stringify({ text })
    });

    const data = await response.json();

    if (data.error) {
        status.textContent = data.error;
        return;
    }

    status.textContent =
        `Added ${data.added.length} memory${data.added.length === 1 ? "" : "ies"}.`;

    document.getElementById("message").value = "";

    renderMemories(data.all);

} catch (error) {
    status.textContent =
        "Could not connect to the local AI server.";
}
Enter fullscreen mode Exit fullscreen mode

}

async function ask() {

const question = document.getElementById("question").value;
const answerBox = document.getElementById("answer");

if (!question.trim()) return;

answerBox.innerHTML = "Thinking...";

try {

    const response = await fetch("/ask", {
        method: "POST",
        headers: {
            "Content-Type": "application/json"
        },
        body: JSON.stringify({ question })
    });

    const data = await response.json();

    answerBox.innerHTML =
        `&lt;div class="answer"&gt;${escapeHtml(data.answer)}&lt;/div&gt;`;

} catch (error) {

    answerBox.innerHTML =
        `&lt;div class="answer"&gt;Something went wrong.&lt;/div&gt;`;
}
Enter fullscreen mode Exit fullscreen mode

}

async function loadMemories() {

const response = await fetch("/memories");
const memories = await response.json();

renderMemories(memories);
Enter fullscreen mode Exit fullscreen mode

}

function renderMemories(memories) {

const container = document.getElementById("memories");

if (!memories.length) {

    container.innerHTML =
        `&lt;div class="memory"&gt;
            No memories yet. Add something above.
        &lt;/div&gt;`;

    return;
}

container.innerHTML = memories
    .slice()
    .reverse()
    .map(memory =&gt; `
        &lt;div class="memory"&gt;
            &lt;div class="memory-type"&gt;
                ${escapeHtml(memory.type || "memory")}
            &lt;/div&gt;

            &lt;div class="memory-title"&gt;
                ${escapeHtml(memory.title || "Memory")}
            &lt;/div&gt;

            &lt;div class="memory-detail"&gt;
                ${escapeHtml(memory.detail || "")}
            &lt;/div&gt;
        &lt;/div&gt;
    `)
    .join("");
Enter fullscreen mode Exit fullscreen mode

}

async function clearMemories() {

if (!confirm("Delete all memories?")) return;

await fetch("/clear", {
    method: "POST"
});

loadMemories();
Enter fullscreen mode Exit fullscreen mode

}

function escapeHtml(text) {

const div = document.createElement("div");
div.textContent = text;

return div.innerHTML;
Enter fullscreen mode Exit fullscreen mode

}

loadMemories();

How I Built It

Frontend: plain HTML/CSS/JS

AI: Ollama
Model: Qwen2.5 3B (open-weight)
Backend: Python standard library
Storage: local JSON
Internet: not required once the model is installed
API keys: none

<!DOCTYPE html>



FriendMemory
  • { box-sizing: border-box; }

body {
margin: 0;
font-family: Inter, system-ui, sans-serif;
background: #0b1020;
color: #f8fafc;
}

.container {
max-width: 900px;
margin: auto;
padding: 50px 20px;
}

.hero {
margin-bottom: 35px;
}

.badge {
display: inline-block;
background: #172554;
color: #93c5fd;
padding: 7px 12px;
border-radius: 999px;
font-size: 13px;
margin-bottom: 15px;
}

h1 {
font-size: 48px;
margin: 0 0 10px;
}

.subtitle {
color: #94a3b8;
font-size: 18px;
line-height: 1.6;
}

.card {
background: #111827;
border: 1px solid #1f2937;
border-radius: 18px;
padding: 22px;
margin-bottom: 20px;
}

textarea,
input {
width: 100%;
background: #020617;
color: white;
border: 1px solid #334155;
border-radius: 12px;
padding: 15px;
font-size: 15px;
outline: none;
}

textarea {
min-height: 150px;
resize: vertical;
}

textarea:focus,
input:focus {
border-color: #6366f1;
}

button {
border: 0;
border-radius: 10px;
padding: 12px 18px;
background: #6366f1;
color: white;
font-weight: 700;
cursor: pointer;
margin-top: 12px;
}

button:hover {
background: #4f46e5;
}

.secondary {
background: #1e293b;
}

.memories {
display: grid;
gap: 12px;
margin-top: 18px;
}

.memory {
background: #020617;
border: 1px solid #1e293b;
padding: 16px;
border-radius: 12px;
}

.memory-type {
color: #818cf8;
text-transform: uppercase;
font-size: 11px;
font-weight: bold;
letter-spacing: 1px;
}

.memory-title {
font-size: 17px;
font-weight: 700;
margin-top: 5px;
}

.memory-detail {
color: #94a3b8;
margin-top: 5px;
}

.answer {
margin-top: 15px;
padding: 15px;
background: #172554;
border-radius: 12px;
color: #bfdbfe;
}

.status {
color: #94a3b8;
margin-top: 10px;
min-height: 20px;
}

.privacy {
text-align: center;
color: #64748b;
margin-top: 30px;
font-size: 13px;
}

    LOCAL AI · OPEN WEIGHT · PRIVATE

    <h1>🧠 FriendMemory</h1>


        A tiny AI memory assistant for someone you care about.
        Turn messy messages into memories you can actually find later.




    <h2>💬 Remember something</h2>



    Remember this





    <h2>🔎 Ask your memories</h2>



    Ask





    <h2>💭 Your memories</h2>




        Clear everything




    🔒 Your memories stay on your computer.
    AI inference happens locally through Ollama.
Enter fullscreen mode Exit fullscreen mode

async function remember() {

const text = document.getElementById("message").value;
const status = document.getElementById("status");

if (!text.trim()) return;

status.textContent = "Thinking...";

try {

    const response = await fetch("/remember", {
        method: "POST",
        headers: {
            "Content-Type": "application/json"
        },
        body: JSON.stringify({ text })
    });

    const data = await response.json();

    if (data.error) {
        status.textContent = data.error;
        return;
    }

    status.textContent =
        `Added ${data.added.length} memory${data.added.length === 1 ? "" : "ies"}.`;

    document.getElementById("message").value = "";

    renderMemories(data.all);

} catch (error) {
    status.textContent =
        "Could not connect to the local AI server.";
}
Enter fullscreen mode Exit fullscreen mode

}

async function ask() {

const question = document.getElementById("question").value;
const answerBox = document.getElementById("answer");

if (!question.trim()) return;

answerBox.innerHTML = "Thinking...";

try {

    const response = await fetch("/ask", {
        method: "POST",
        headers: {
            "Content-Type": "application/json"
        },
        body: JSON.stringify({ question })
    });

    const data = await response.json();

    answerBox.innerHTML =
        `&lt;div class="answer"&gt;${escapeHtml(data.answer)}&lt;/div&gt;`;

} catch (error) {

    answerBox.innerHTML =
        `&lt;div class="answer"&gt;Something went wrong.&lt;/div&gt;`;
}
Enter fullscreen mode Exit fullscreen mode

}

async function loadMemories() {

const response = await fetch("/memories");
const memories = await response.json();

renderMemories(memories);
Enter fullscreen mode Exit fullscreen mode

}

function renderMemories(memories) {

const container = document.getElementById("memories");

if (!memories.length) {

    container.innerHTML =
        `&lt;div class="memory"&gt;
            No memories yet. Add something above.
        &lt;/div&gt;`;

    return;
}

container.innerHTML = memories
    .slice()
    .reverse()
    .map(memory =&gt; `
        &lt;div class="memory"&gt;
            &lt;div class="memory-type"&gt;
                ${escapeHtml(memory.type || "memory")}
            &lt;/div&gt;

            &lt;div class="memory-title"&gt;
                ${escapeHtml(memory.title || "Memory")}
            &lt;/div&gt;

            &lt;div class="memory-detail"&gt;
                ${escapeHtml(memory.detail || "")}
            &lt;/div&gt;
        &lt;/div&gt;
    `)
    .join("");
Enter fullscreen mode Exit fullscreen mode

}

async function clearMemories() {

if (!confirm("Delete all memories?")) return;

await fetch("/clear", {
    method: "POST"
});

loadMemories();
Enter fullscreen mode Exit fullscreen mode

}

function escapeHtml(text) {

const div = document.createElement("div");
div.textContent = text;

return div.innerHTML;
Enter fullscreen mode Exit fullscreen mode

}

loadMemories();

Prize Categories

Best Use of Arduino

Top comments (0)