A Feature Sprint With Decisions In It
Some problem-solving continued, but October became a feature sprint. The OpenAI service, suggestion UI, journal workspace and optimistic creation all shipped. The build also raised questions alongside initial implementations: how to deliver context to the AI without diluting it, how to wrap a synchronous SDK in an async service, how to keep AI context grounded in confirmed nodes and how scoping trees to individual thoughts could resolve the drag-lag from Phase 5.
The OpenAI Service (30 Sep)
Commit: API bef3252
– "OpenAI client integration with mock support and suggestion endpoints"
Simply a service function – no state carried between calls:
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
async def generate_suggestions(
context_nodes: List[ContextNode],
limit: int = 3,
target_type: NodeType = NodeType.prompt,
) -> List[NodeSuggestion]:
client = get_openai_client()
context_summary = "\n".join(
f"[{n.node_type}] {n.content}" for n in context_nodes[-5:] if n.content
)
config = SUGGESTION_CONFIG[target_type]
try:
response = await asyncio.to_thread(
client.chat.completions.create,
model="gpt-4o-mini",
messages=[
{"role": "system", "content": config["system_msg"]},
{"role": "user", "content": f"Context:\n{context_summary}\n\nSuggest {limit} new {target_type.value}(s)."},
],
temperature=config["temperature"],
max_tokens=config["max_tokens"],
n=1,
)
except Exception:
return []
suggestions = []
for raw in response.choices[0].message.content.split("\n"):
if raw.strip():
suggestions.append(NodeSuggestion(
content=raw.strip(),
source_context=[n.id for n in context_nodes],
))
return suggestions[:limit]
asyncio.to_thread() wraps the synchronous SDK calls and hands them off to
a thread pool, keeping FastAPI's event loop free.
The broad except Exception: return [] was a testing shortcut – the kind
of temporary code that tends to stick around long after it should. My inner critic
had notes.
Stage 1 of a Two-Stage System
This implementation was always a starting point. The intended architecture was a two-stage pipeline: an InPromptOut context layer that builds and weights context from the graph, in turn forwarding it to whichever AI model the user chooses.
Within that context layer is where the interesting work exists. Rather than
taking recent nodes, it would traverse the semantic edges of the graph –
inspires, clarifies, expands, etc – and factor in how recently
a thought had been activated, how deeply it was positioned in the hierarchy and how
it was weighted relative to other nodes. Multi-model support was planned alongside
this: gpt-4o-mini as the default.
This layer is still in development post-launch. Getting the data structures, context pipeline and suggestion endpoint working end-to-end was the prerequisite.
Suggestion Config and Mock Client
Each node type has its own system prompt, temperature and token budget in
suggestion_config.py – working values, not calibrated ones. Mock mode
handled testing without real API calls.
Dual-Mode Node Creation (9 Oct)
Commit: Web d5add84
– "AI-powered node suggestions with dual-mode creation and context system"
The node creation UI gained two modes: left button opens an empty editor for manual
input; right button fetches a suggestion before showing the editor. The split is
handled by a single useAI boolean through the component tree:
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
const handleAddChild = async (parentId: string, nodeType: NodeType, useAI: boolean) => {
const newNodeId = typeof crypto?.randomUUID === 'function'
? crypto.randomUUID()
: `pending-${Date.now()}`;
if (!useAI) {
// Manual mode: show empty editor immediately
setPendingNodes(prev => [...prev, { id: newNodeId, parentId, nodeType }]);
return;
}
// AI mode: fetch first suggestion and pre-fill editor
if (!userId) {
handleError('User not authenticated - cannot fetch suggestions');
return;
}
try {
const contextNodes = buildContextForSuggestion(treeItems, parentId, userId);
const response = await nodesUnified.suggest({
user_id: userId,
context_nodes: contextNodes,
target_node_type: nodeType,
limit: 1
});
// ...
}
};
Context Strategy
In a graph, recency is the wrong signal. What matters is proximity – the nodes
structurally adjacent to where the user is working. The neighbourhood approach in
buildContextForSuggestion.ts was deliberate from the start and always
intended as a foundation to build on:
| Phase 7 | Phase 9 | |
|---|---|---|
| Context nodes | Parent, siblings, grandparent | Same neighbourhood |
| Privacy filtering | None – all neighbours included | Nodes marked private are excluded |
| User control | None | Per-node toggle: private / visible / pinned |
| Content extraction | content field only |
Per-type (prompt, result, thought) |
| Return type | ContextNode[] |
ContextBuildResult with filter counts |
The initial fetch also requests one suggestion (limit: 1) – most users
accept or ignore the first without navigating further and fetching three upfront would
burn tokens on suggestions that never get seen. buildContextForSuggestion
is called with treeItems, which contains only server-confirmed nodes;
pending nodes live in a separate state and never reach the context builder.
The Workspace Takes Shape (13–16 Oct)
Commits: Web d3c3a89 (13 Oct),
Web 016a6b0 (16 Oct)
d3c3a89 finally gave the app shape: a
Journals list page, a JournalWorkspace that loaded a real graph
from the API and a JournalEditor for metadata.
016a6b0 restructured how the workspace displayed content
and resolved the Phase 5 drag-lag in the process. The root cause was that @dnd-kit's
sensor initialisation sweeps bounding rects for every droppable element on pointer down;
a journal rendered as a single tree meant that cost scaled with journal size. There was no
fix at the @dnd-kit layer that didn't mean rewriting the tree component. The only real
solution was to never render a large tree.
016a6b0 did that by changing what the tree represents. A sidebar now lists
the thoughts within a journal; selecting one calls filterSubgraph() on
TreeDataAdapter to extract just that thought's descendants via BFS
(implementation shown in Phase 5) and passes only those nodes to the tree. Individual
thought subgraphs rarely exceed ten or fifteen nodes – the sensor initialisation cost
that triggered the lag at 20-plus nodes is no longer reachable in normal use.
A Lucky Find
The fix for a technical problem and a significant UX improvement arrived together, neither planned as a consequence of the other. The left-hand panel listing pages or layers is familiar from document editors and image editors – but this was different. The right panel wasn't a page or a canvas; it was a thought's own graph, collapsible and navigable. It felt natural to work with in a way the single-tree journal view hadn't and it gave the app the hierarchical, threaded quality it had always needed.
The structural change came with a net reduction: JournalWorkspace.tsx shed
160+ lines of static example data. New components – the sidebar, modal thought creation,
auto-selection on load – were added in the same commit. Net change across all files:
−108 lines. Adding the right structure and removing the scaffold it replaced landed
cleaner than either alone.
Optimistic Creation and Loading States (17–18 Oct)
Commits: Web 4205cd2 (17 Oct),
Web 8dcd0e3 (18 Oct),
API f580372 (18 Oct)
Two separate "don't make the user wait" improvements landed on consecutive days.
4205cd2 made node saves optimistic. Previously, committing a new node meant
waiting for the API response before it appeared in the tree. After: a tempId
is assigned immediately, the node appears at once and the save happens in the background.
When the server responds, mutations.getTempIdMappings() walks the tree and
replaces every reference to the tempId with the real server ID.
8dcd0e3 applied the same principle to AI suggestion fetches. In
d5add84, the pending node only appeared after suggestions were returned –
the user clicked the AI button and waited with nothing on screen. 8dcd0e3
flipped that: the pending node is added to the tree immediately in a loading state,
and the suggestion fetch happens while the editor is already visible. The messages
are drawn at random from a dedicated constants file:
1
2
3
4
5
6
7
8
9
10
11
12
13
export const AI_LOADING_MESSAGES = [
"Consulting the creative cosmos",
"Summoning inspiration",
"Brewing ideas",
"Gathering scattered thoughts",
"Asking the muses nicely",
"Connecting neural pathways",
"Spinning up the imagination engine",
"Thinking deeply about things",
"Wandering through possibility space",
"Distilling creativity",
// ... 5 more
] as const;
Finally, f580372 added CreateNodeOp support and graph
traversal endpoints to the API, completing the round-trip that optimistic node creation
now depended on.
By the end of October the full loop was running: open a journal, select a thought, add a child node in AI mode, get a real suggestion against real graph context. It was also around this point that the project started to feel more clearly like something. Until now it had been a genuinely interesting build, but the AI feature being live – seeing the graph context being used to generate something useful rather than generic – clarified a big part of what the app was actually for.
October also shifted how I was using AI to build it. Earlier in the project I would often know the answer to something but reach for AI to confirm it – less about getting help than about having something to check against. By October those sessions were shorter and more focused and the useful ones were often the disagreements rather than the agreements. Working through a problem alongside AI, pushing back, arriving at something better than either starting position – that pattern was the original inspiration for InPromptOut.