Context and Memory Workspace - Source
Selected pieces of the workspace: request handling, background jobs, memory updates, and the connections between them. The excerpts are shortened for the portfolio, with private identifiers and access-sensitive details removed.
README · ARCHITECTURE · TECHNICAL · source
Find an excerpt
- 1. The entry point
- 2. The batch selector
- 3. The attractor state model
- 4. Applying an update
- 5. Growing the knowledge graph
- 6. Embeddings
- 7. Configuration is part of the behavior
1. The entry point
One fetch handler, one scheduled handler, one queue handler. Route handlers
return a Response if they matched and null if they did not, so the whole router
is a ?? chain.
export default {
async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
try {
const url = new URL(request.url);
const path = url.pathname;
const method = request.method;
// Only handle /api routes — everything else is served as static assets by Cloudflare
if (!path.startsWith("/api/")) {
return env.ASSETS.fetch(request);
}
if (method === "POST" && path === "/api/auth/login") {
return handleLogin(request, env);
}
// Asset-delivery routes and their access boundaries are omitted here.
// --- Auth middleware for all other /api routes ---
const authError = await authenticate(request, env);
if (authError) return authError;
// ...model-call rate limiting omitted from this public excerpt...
// Order matters: specific routes before parameterized ones.
// Each handler returns Response if matched, null if not.
const response = await handleConversationRoutes(method, path, url, request, env, ctx)
?? await handleTagRoutes(method, path, url, request, env)
?? await handleSearchRoutes(method, path, url, request, env)
?? await handleSemanticSearchRoutes(method, path, url, request, env)
?? await handleWikiRoutes(method, path, url, request, env, ctx)
?? await handleClaudeRoutes(method, path, url, request, env, ctx)
?? await handleAttractorRoutes(method, path, url, request, env)
?? await handleBranchRoutes(method, path, url, request, env);
// ...eight more handlers elided
return response ?? error("Not found", 404);
} catch (e: any) {
return new Response(JSON.stringify({ error: `Internal error: ${e.message}` }), {
status: 500,
headers: { "Content-Type": "application/json", "Access-Control-Allow-Origin": "*" },
});
}
},
// ...scheduled handler omitted; it enqueues background work...
async queue(batch: MessageBatch<QueueJob>, env: Env): Promise<void> {
await handleQueue(batch, env);
},
};
2. The batch selector
summarize_batch does not summarize anything. It picks work and fans it out.
case "summarize_batch": {
const { results } = await env.DB.prepare(`
SELECT c.id FROM conversations c
WHERE EXISTS (
SELECT 1 FROM messages m
WHERE m.conversation_id = c.id
AND (c.last_summarized_at IS NULL OR m.created_at > c.last_summarized_at)
)
AND (SELECT COUNT(*) FROM messages WHERE conversation_id = c.id) >= 4
LIMIT 20
`).all<{ id: string }>();
if (results.length > 0) {
await env.JOBS.sendBatch(
results.map((c) => ({
body: {
type: "summarize_conversation" as const,
conversationId: c.id,
},
}))
);
}
break;
}
The >= 4 floor and the LIMIT 20 are the two numbers that keep this cheap: no
model call for a conversation with nothing in it, and no single cron tick that can
enqueue the entire corpus.
3. The attractor state model
The memory structure itself. Every field is something I needed to be able to read back and check.
// ═══════════════════════════════════════════════════════════════
// The Attractor — Persistent Topological Memory
// ═══════════════════════════════════════════════════════════════
//
// A strange attractor in concept-space that evolves through
// conversation. Each "basin" is a mode of engagement that pulls
// future conversations toward it. The attractor state persists
// in KV and gets injected into the system prompt, creating a
// feedback loop: conversation → attractor → system prompt → conversation.
export interface AttractorBasin {
id: string;
label: string; // Human-readable name ("Cloudflare Infrastructure")
description: string; // What this basin represents
weight: number; // 0–1, activation strength (decays over time, reinforced by conversations)
keywords: string[]; // Representative concepts (max 10)
connections: string[]; // IDs of connected basins (shared concepts bridge them)
trajectory: number[]; // Weight history — last 20 snapshots
lastActive: string; // ISO timestamp of last reinforcement
conversationCount: number; // How many conversations have touched this basin
}
export interface AttractorState {
basins: AttractorBasin[];
phase: number; // Era/epoch — increments when the attractor restructures significantly
entropy: number; // 0–1, topic spread (0 = all energy in one basin, 1 = uniform)
emerging: string[]; // Patterns forming that aren't basins yet (max 5)
lastUpdated: string;
updateCount: number;
meta: {
totalConversations: number;
dominantBasin: string;
recentTrajectory: "converging" | "diverging" | "stable" | "restructuring";
};
}
The update rules — decay constants, clamps, the entropy definition — are written out in full in the Attractor project.
4. Applying an update
The queue job that closes the feedback loop. Note how much of it is refusing to act on insufficient input.
case "attractor_update": {
const state = await getAttractorState(env.MODEL_KV);
if (!state) {
console.log("Attractor not initialized, skipping update for", job.conversationId);
break;
}
const conv = await env.DB.prepare(
"SELECT summary, vibes FROM conversations WHERE id = ?"
).bind(job.conversationId).first<{ summary: string | null; vibes: string | null }>();
if (!conv?.summary) {
console.log("No summary for conversation", job.conversationId, "— skipping attractor update");
break;
}
let vibes: string[] = [];
try { if (conv.vibes) vibes = JSON.parse(conv.vibes); } catch { /* ignore */ }
const update = await generateAttractorUpdate(env, state, conv.summary, vibes);
const newState = applyUpdate(state, update);
await saveAttractorState(env.MODEL_KV, newState);
console.log(
`Attractor updated: ${update.basin_updates.length} basins touched, ` +
`entropy ${newState.entropy.toFixed(3)}, trajectory: ${newState.meta.recentTrajectory}`
);
break;
}
An unsummarized conversation produces no attractor update at all. Feeding raw transcripts to the update model was the first version and it made the basins track whatever vocabulary happened to appear, rather than what the conversation was about.
5. Growing the knowledge graph
Branches are found or created from summarization output. Nothing is tagged by hand.
function slugify(name: string): string {
return name.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "").slice(0, 100);
}
export async function processBranchUpdate(
env: Env,
conversationId: string,
branchUpdates: Array<{ name: string; relevance: number; state_hint: string | null }>,
themeSignals: string[]
): Promise<void> {
const touchedBranchIds: Array<{ id: string; name: string }> = [];
for (const bu of branchUpdates) {
const slug = slugify(bu.name);
if (!slug) continue;
let branch = await env.DB.prepare(
"SELECT id, state, conversation_count FROM branches WHERE slug = ?"
).bind(slug).first<{ id: string; state: string; conversation_count: number }>();
if (!branch) {
const id = generateId();
await env.DB.prepare(
"INSERT INTO branches (id, name, slug, state) VALUES (?, ?, ?, 'seedling')"
).bind(id, bu.name, slug).run();
branch = { id, state: "seedling", conversation_count: 0 };
// Embed new branch for semantic search
await enqueueEmbed(env, "branch", id);
}
touchedBranchIds.push({ id: branch.id, name: bu.name });
await env.DB.prepare(
"INSERT OR REPLACE INTO conversation_branches (conversation_id, branch_id, relevance) VALUES (?, ?, ?)"
).bind(conversationId, branch.id, bu.relevance).run();
}
// ...theme linking and convergence detection elided
}
Everything new enters as a seedling. A branch earns a stronger state by recurring,
which means the graph reflects what I actually kept coming back to rather than what
I once intended to work on.
6. Embeddings
Four content types share one index. The constants are set by the model's limits, not chosen freely.
const EMBEDDING_MODEL = "@cf/baai/bge-base-en-v1.5";
// bge-base-en-v1.5 has 512 token input limit; ~4 chars per token = ~2000 chars safe
const MAX_INPUT_CHARS = 1800;
const CHUNK_CHARS = 1800;
const CHUNK_OVERLAP = 200;
const MAX_CHUNKS_PER_PAGE = 3;
export interface VectorMeta {
content_type: "conversation" | "wiki" | "branch" | "theme";
title: string;
source_id: string;
}
export async function generateEmbedding(ai: Ai, text: string): Promise<number[]> {
const truncated = text.slice(0, MAX_INPUT_CHARS);
const result: { data: number[][] } = await ai.run(EMBEDDING_MODEL, { text: [truncated] });
return result.data[0];
}
export function prepareConversationText(conv: {
summary: string;
vibes?: string[];
concepts?: string[];
turning_points?: Array<{ moment: string }>;
}): string {
const parts = [conv.summary];
if (conv.vibes?.length) parts.push(`Vibes: ${conv.vibes.join(", ")}`);
if (conv.concepts?.length) parts.push(`Topics: ${conv.concepts.join(", ")}`);
if (conv.turning_points?.length) {
parts.push(`Key moments: ${conv.turning_points.map((t) => t.moment).join("; ")}`);
}
return parts.join(". ");
}
A conversation is embedded as its summary plus its derived structure, not as its transcript. The transcript is mostly turn-taking; the summary is what the conversation was for. In my own use, searching the derived form has been more useful than matching the vocabulary of an entire transcript. That is a workflow observation, not a retrieval benchmark.
7. Configuration is part of the behavior
The deployment configuration chooses the Worker entry point, asset routing, runtime compatibility, schedules, and every storage or AI binding. Those values are part of the application, not background paperwork.
I found that out through configuration drift: stale copies pointed at the wrong entry points, and one file did not describe a binding the running Worker needed. I kept one canonical config beside the Worker and left the reasons for unusual values in comments. The exact binding names and schedules stay with the private project; the useful lesson is public: a deploy file can break working code without changing the code at all.