Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -57,3 +57,7 @@ data/
data/db
data/logs

# --- Local planning / prompt notes (not part of the product) ---
CLAUDE_CODE_KICKOFF_PROMPT.md
CONTENT_OS_BUILD_PLAN.md

31 changes: 31 additions & 0 deletions HOW-TO-OPEN-PROJECT-TRACKER.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,3 +50,34 @@ To add it to your Start menu too:
- Choose **Show more options** (if you see it), then **Pin to Start**

Now you can open Project Tracker any time with one click. Done!

## 8. (Optional) Let Claude help you with your content

Project Tracker can connect to the **Claude Desktop app** so you can just *ask*
Claude to plan, write, move, and schedule your content — instead of filling in
forms yourself.

**What you need:**

- The **Claude Desktop app** installed on this computer and signed in.
(You can download it from Anthropic. Any Claude plan that supports connectors
works.) If you don't have Claude Desktop, the rest of Project Tracker still
works perfectly — this part is optional.

**What it gives you (for free):**

Once connected, you can open Claude Desktop and say things like:

- *"What content do I have scheduled this week?"*
- *"Write 5 Instagram captions about summer skincare and add them to my calendar
for next week."*
- *"Move Tuesday's post to Friday."*
- *"Approve the skincare post."*

Claude does the thinking on **your Claude subscription**, so this doesn't use up
any paid AI credits. (There's also a **✨ Generate** button *inside* Project
Tracker that writes content for you — that one needs your own AI key, added in
**AI Settings**. It's optional; Claude Desktop can do the same thing for free.)

**How to connect:** see `mcp_server/README.md` for the one-time setup step, or ask
whoever set up your Project Tracker to turn it on for you.
Empty file added ai_assistant/__init__.py
Empty file.
77 changes: 77 additions & 0 deletions ai_assistant/agent.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
# ai_assistant/agent.py
"""The agent loop: send the conversation + tool schemas to the active provider,
execute whatever tool call comes back, feed the result in, repeat until the model
replies in plain text.

Conversation messages use ai_client's normalized shape (plain dicts, so they
round-trip through the Django session):
{"role": "user"|"assistant", "content": str, ["tool_calls": [...]]}
{"role": "tool", "tool_call_id": str, "name": str, "content": str}
"""
import json

from ai_settings import ai_client

from . import tools

MAX_STEPS = 6 # safety cap on tool round-trips per user turn

SYSTEM_PROMPT = (
"You are the content assistant inside a personal content calendar app. "
"You help plan, draft, schedule and review social content by calling the "
"provided tools. Today's date is available to you from context if given. "
"When the user asks to create, list, move, approve, or generate content, "
"use the tools rather than only describing what to do. After acting, briefly "
"confirm what you did in plain language. Dates are YYYY-MM-DD. If a request "
"is ambiguous, ask a short clarifying question instead of guessing."
)


def run_turn(messages: list[dict], system_prompt: str = SYSTEM_PROMPT) -> tuple[str, list[dict]]:
"""Advance the conversation by one user turn (which may involve several tool
calls). `messages` should already include the latest user message. Returns
(assistant_reply_text, updated_messages)."""
for _ in range(MAX_STEPS):
turn = ai_client.generate_with_tools(
messages=messages,
tools=tools.tool_schemas(),
system_prompt=system_prompt,
)

if turn.tool_calls:
messages.append({
"role": "assistant",
"content": turn.text or "",
"tool_calls": [
{"id": tc.id, "name": tc.name, "arguments": tc.arguments}
for tc in turn.tool_calls
],
})
for tc in turn.tool_calls:
result = tools.execute_tool(tc.name, tc.arguments)
messages.append({
"role": "tool",
"tool_call_id": tc.id,
"name": tc.name,
"content": json.dumps(result, default=str),
})
continue

reply = turn.text or ""
messages.append({"role": "assistant", "content": reply})
return reply, messages

fallback = "I stopped after several steps without finishing. Could you narrow the request?"
messages.append({"role": "assistant", "content": fallback})
return fallback, messages


def display_messages(messages: list[dict]) -> list[dict]:
"""Just the user/assistant text turns, for rendering the chat transcript."""
out = []
for m in messages:
if m["role"] == "user" and m.get("content"):
out.append({"role": "user", "content": m["content"]})
elif m["role"] == "assistant" and m.get("content") and not m.get("tool_calls"):
out.append({"role": "assistant", "content": m["content"]})
return out
7 changes: 7 additions & 0 deletions ai_assistant/apps.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
from django.apps import AppConfig


class AiAssistantConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "ai_assistant"
verbose_name = "AI Assistant"
Empty file.
Empty file.
29 changes: 29 additions & 0 deletions ai_assistant/management/commands/connect_claude.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
# ai_assistant/management/commands/connect_claude.py
import sys
from pathlib import Path

from django.core.management.base import BaseCommand

from mcp_server.desktop_connect import claude_config_path, connect


class Command(BaseCommand):
help = "Register this install with Claude Desktop (adds the content-calendar MCP server)."

def handle(self, *args, **options):
base_dir = Path(__file__).resolve().parents[3]
frozen = getattr(sys, "frozen", False)
status = connect(base_dir, frozen)

cfg = claude_config_path()
if status in ("connected", "updated", "unchanged"):
self.stdout.write(self.style.SUCCESS(
f"Claude Desktop: {status} ({cfg})."
))
self.stdout.write("Fully quit and reopen Claude Desktop to load the change.")
elif status == "no-claude":
self.stdout.write(self.style.WARNING(
"Claude Desktop not found — nothing changed. Install Claude Desktop, then run this again."
))
else:
self.stderr.write(self.style.ERROR(f"Could not connect: {status}"))
15 changes: 15 additions & 0 deletions ai_assistant/management/commands/runmcp.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
# ai_assistant/management/commands/runmcp.py
from django.core.management.base import BaseCommand


class Command(BaseCommand):
help = "Run the MCP server over stdio so Claude Desktop/Code can manage the calendar."

def handle(self, *args, **options):
# Django is already configured here; importing the server registers the
# tools against the same models the app uses. stdout is reserved for the
# MCP protocol, so status goes to stderr.
from mcp_server.server import mcp

self.stderr.write("Starting content-calendar MCP server (stdio)…")
mcp.run(transport="stdio")
20 changes: 20 additions & 0 deletions ai_assistant/templates/ai_assistant/_messages.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
{% comment %}The chat transcript — returned on its own by the send endpoint and
swapped into #chat-messages. Expects `messages` and optional `error`.{% endcomment %}
{% for m in messages %}
{% if m.role == 'user' %}
<div class="flex justify-end">
<div class="max-w-[80%] bg-indigo-600 text-white rounded-2xl rounded-br-sm px-4 py-2 text-sm whitespace-pre-wrap">{{ m.content }}</div>
</div>
{% else %}
<div class="flex justify-start">
<div class="max-w-[80%] bg-white border border-gray-200 text-gray-800 rounded-2xl rounded-bl-sm px-4 py-2 text-sm whitespace-pre-wrap">{{ m.content }}</div>
</div>
{% endif %}
{% empty %}
<p class="text-center text-gray-400 text-sm py-8">Ask me to plan, draft, schedule, or review your content.</p>
{% endfor %}
{% if error %}
<div class="flex justify-start">
<div class="max-w-[80%] bg-red-50 border border-red-200 text-red-800 rounded-2xl px-4 py-2 text-sm">{{ error }}</div>
</div>
{% endif %}
100 changes: 100 additions & 0 deletions ai_assistant/templates/ai_assistant/chat.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
{% extends 'base.html' %}
{% block title %}Assistant - {{ site_config.site_name }}{% endblock %}

{% block content %}
<div class="container mx-auto px-4 py-8 max-w-2xl">
<div class="flex items-center justify-between mb-4">
<h1 class="text-3xl font-bold text-gray-900">Assistant</h1>
<form method="post" action="{% url 'ai_assistant:clear' %}">
{% csrf_token %}
<button type="submit" class="text-sm text-gray-500 hover:text-gray-800">Clear chat</button>
</form>
</div>

<div class="bg-gray-50 border border-gray-200 rounded-lg flex flex-col" style="height: 65vh;">
<div id="chat-messages" class="flex-1 overflow-y-auto p-4 space-y-3">
{% include "ai_assistant/_messages.html" %}
</div>

<form id="chat-form" class="border-t border-gray-200 p-3 flex items-end gap-2 bg-white rounded-b-lg">
{% csrf_token %}
<textarea id="chat-input" name="message" rows="1" placeholder="Message the assistant…"
class="flex-1 resize-none border border-gray-300 rounded-md py-2 px-3 text-sm focus:outline-none focus:ring-indigo-500 focus:border-indigo-500"></textarea>
<button type="submit" id="chat-send"
class="bg-indigo-600 hover:bg-indigo-700 text-white text-sm font-semibold py-2 px-4 rounded-md disabled:opacity-50">Send</button>
</form>
</div>
<p class="text-xs text-gray-400 mt-2">Try: “Give me 5 August Instagram post ideas about summer skincare” or “Move item 3 to next Friday”.</p>
</div>
{% endblock %}

{% block extra_js %}
<script>
(function () {
const form = document.getElementById("chat-form");
const input = document.getElementById("chat-input");
const sendBtn = document.getElementById("chat-send");
const messages = document.getElementById("chat-messages");
const csrftoken = form.querySelector("[name=csrfmiddlewaretoken]").value;

function scrollDown() { messages.scrollTop = messages.scrollHeight; }
scrollDown();

function appendUserBubble(text) {
const wrap = document.createElement("div");
wrap.className = "flex justify-end";
wrap.innerHTML = '<div class="max-w-[80%] bg-indigo-600 text-white rounded-2xl rounded-br-sm px-4 py-2 text-sm whitespace-pre-wrap"></div>';
wrap.firstChild.textContent = text;
messages.appendChild(wrap);
}

function appendThinking() {
const wrap = document.createElement("div");
wrap.id = "thinking";
wrap.className = "flex justify-start";
wrap.innerHTML = '<div class="bg-white border border-gray-200 text-gray-400 rounded-2xl rounded-bl-sm px-4 py-2 text-sm">Thinking…</div>';
messages.appendChild(wrap);
}

async function send() {
const text = input.value.trim();
if (!text) return;
input.value = "";
input.style.height = "auto";
sendBtn.disabled = true;
appendUserBubble(text);
appendThinking();
scrollDown();

try {
const body = new FormData();
body.append("message", text);
const resp = await fetch("{% url 'ai_assistant:send' %}", {
method: "POST",
headers: { "X-CSRFToken": csrftoken },
body: body,
});
messages.innerHTML = await resp.text();
} catch (e) {
const t = document.getElementById("thinking");
if (t) t.querySelector("div").textContent = "Request failed. Try again.";
} finally {
sendBtn.disabled = false;
scrollDown();
input.focus();
}
}

form.addEventListener("submit", function (e) { e.preventDefault(); send(); });
// Enter to send, Shift+Enter for newline.
input.addEventListener("keydown", function (e) {
if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); send(); }
});
// Auto-grow the textarea.
input.addEventListener("input", function () {
input.style.height = "auto";
input.style.height = Math.min(input.scrollHeight, 120) + "px";
});
})();
</script>
{% endblock %}
Loading
Loading