Skip to content

Repository files navigation

convo-tree

Tree-structured conversation state manager for branching chats.

npm versionnpm downloadslicensenode

convo-tree models a conversation as a rooted tree where each node holds a message (system, user, assistant, or tool), children represent alternative continuations from the same point, and any root-to-leaf path is one complete linear conversation. The core metaphor is git: fork() is git branch, switchTo() is git checkout, getActivePath() is git log --first-parent, and prune() is git branch -D.

The package is a pure data structure with zero runtime dependencies and no network I/O. It manages the tree; the caller manages LLM interactions. Extract the active path with getActivePath(), send it to any LLM provider, and add the response back with addMessage().

Installation

npm install convo-tree

Requires Node.js 18 or later.

Quick Start

import{createConversationTree}from'convo-tree';// Create a tree with an automatic system prompt root nodeconsttree=createConversationTree({systemPrompt: 'You are a helpful assistant.',});// Build a conversation by appending messagestree.addMessage('user','Hello!');tree.addMessage('assistant','Hi there! How can I help?');tree.addMessage('user','Tell me a joke.');tree.addMessage('assistant','Why did the chicken cross the road?');// Extract the active path as a flat message array for any LLM APIconstmessages=tree.getActivePath();// [// { role: 'system', content: 'You are a helpful assistant.' },// { role: 'user', content: 'Hello!' },// { role: 'assistant', content: 'Hi there! How can I help?' },// { role: 'user', content: 'Tell me a joke.' },// { role: 'assistant', content: 'Why did the chicken cross the road?' }// ]

Features

  • Branching conversations -- Fork at any point to explore alternative continuations. Multiple branches coexist in a single tree structure.
  • HEAD tracking -- A HEAD pointer tracks the current position. New messages append as children of HEAD, and HEAD advances automatically.
  • Active path extraction -- getActivePath() returns a flat Message[] from root to HEAD, ready to send to any LLM API.
  • Undo/redo -- Navigate backward and forward along the active path without losing history. Adding a new message after undo implicitly creates a new branch.
  • Subtree pruning -- Remove a node and all its descendants in one operation. HEAD relocates automatically if it falls within the pruned subtree.
  • Branch labels -- Assign human-readable labels to branches for organization (e.g., "creative approach", "model: GPT-4o").
  • Node metadata -- Attach arbitrary key-value data to any node (model name, temperature, latency, token count).
  • Event system -- Subscribe to message, fork, switch, and prune events for reactive UI updates and logging.
  • Serialization -- Export the full tree state as a JSON-serializable object for persistence and restoration.
  • Zero dependencies -- Pure data structure using only built-in Node.js APIs (crypto.randomUUID, Date.now).
  • Full TypeScript support -- Written in TypeScript with exported type declarations.

API Reference

createConversationTree(options?)

Factory function that creates and returns a ConversationTree instance.

import{createConversationTree}from'convo-tree';consttree=createConversationTree({systemPrompt: 'You are a helpful assistant.',now: ()=>Date.now(),generateId: ()=>crypto.randomUUID(),});

Options

OptionTypeDefaultDescription
systemPromptstringundefinedIf provided, a system-role node is created automatically as the root.
treeMetaRecord<string, unknown>undefinedArbitrary metadata to associate with the tree itself.
now() => numberDate.nowCustom timestamp function used for createdAt on every new node.
generateId() => stringcrypto.randomUUIDCustom ID generator for node IDs.

tree.addMessage(role, content, metadata?)

Appends a new message node as a child of the current HEAD and advances HEAD to the new node. Clears the redo stack.

Parameters:

ParameterTypeDescription
role'system' | 'user' | 'assistant' | 'tool'The message role.
contentstringThe message content.
metadataRecord<string, unknown>Optional metadata to attach to the node. Defaults to {}.

Returns:ConversationNode -- the newly created node.

constnode=tree.addMessage('user','Hello!',{tokens: 3});// node.id -> unique UUID// node.role -> 'user'// node.content -> 'Hello!'// node.parentId -> ID of the previous HEAD node (or null if first node)// node.children -> []// node.metadata -> { tokens: 3 }// node.createdAt -> timestamp from now()

When called on a node that already has children, the new message becomes a sibling, creating an implicit fork without requiring an explicit fork() call.


tree.fork(nodeId?, label?)

Marks a fork point in the tree. Does not create a new node. If nodeId is provided, that node becomes the fork point; otherwise the current HEAD is used. Optionally assigns a branch label to the fork point node.

Parameters:

ParameterTypeDescription
nodeIdstringOptional. The node ID to fork from. Defaults to the current HEAD.
labelstringOptional. A human-readable label to assign to the fork point node.

Returns:Branch -- an object with forkPointId and optional label.

Throws:InvalidOperationError if the tree is empty. NodeNotFoundError if nodeId does not exist.

constbranch=tree.fork(someNode.id,'alternate-response');// branch.forkPointId -> someNode.id// branch.label -> 'alternate-response'

After calling fork(), use switchTo() to move HEAD to the fork point, then call addMessage() to diverge from the original path.


tree.switchTo(nodeId)

Moves HEAD to any existing node in the tree, changing the active path to the root-to-node path.

Parameters:

ParameterTypeDescription
nodeIdstringThe ID of the node to switch to.

Returns:void

Throws:NodeNotFoundError if the node does not exist.

tree.switchTo(earlierNode.id);// HEAD is now at earlierNode// getActivePath() returns root -> ... -> earlierNode

tree.getActivePath()

Returns the linear message array from root to the current HEAD. The returned array is suitable for direct use with any LLM chat completion API.

Returns:Message[] -- an array of { role, content, ...metadata } objects. Returns an empty array if the tree is empty.

constmessages=tree.getActivePath();// messages[0].role -> 'system' (if systemPrompt was set)// messages[0].content -> 'You are a helpful assistant.'

Metadata fields are spread into the message object. For example, if a node has metadata: { tokens: 5 }, the corresponding message will include tokens: 5 alongside role and content.


tree.getPathTo(nodeId)

Returns the linear message array from root to the specified node, without changing HEAD.

Parameters:

ParameterTypeDescription
nodeIdstringThe ID of the target node.

Returns:Message[]

Throws:NodeNotFoundError if the node does not exist.

constpathA=tree.getPathTo(responseA.id);constpathB=tree.getPathTo(responseB.id);// Compare two branch paths without switching HEAD

tree.undo()

Moves HEAD to its parent node, pushing the current HEAD onto the redo stack. Returns the new HEAD node, or null if HEAD is already at the root or the tree is empty.

Returns:ConversationNode | null

tree.addMessage('user','First');tree.addMessage('assistant','Second');constprevious=tree.undo();// previous.content -> 'First'// tree.getHead().content -> 'First'

tree.redo()

Restores the most recently undone node by popping the redo stack and advancing HEAD. Returns the restored node, or null if the redo stack is empty or invalid.

The redo stack is validated: the node to redo must be a child of the current HEAD. If the tree structure has changed (e.g., via addMessage() or prune()), the redo stack is cleared.

Returns:ConversationNode | null

tree.undo();constrestored=tree.redo();// HEAD is back at the node that was undone

Adding a new message after undo() clears the redo stack, creating an implicit new branch from the undo point.


tree.getHead()

Returns the current HEAD node, or null if the tree is empty.

Returns:ConversationNode | null

consthead=tree.getHead();if(head){console.log(head.role,head.content);}

tree.getNode(nodeId)

Retrieves any node in the tree by its ID.

Parameters:

ParameterTypeDescription
nodeIdstringThe ID of the node to retrieve.

Returns:ConversationNode | undefined

constnode=tree.getNode('some-uuid');if(node){console.log(node.children.length,'children');}

tree.prune(nodeId)

Removes the specified node and all of its descendants from the tree. Updates the parent's children array. If HEAD falls within the pruned subtree, HEAD is moved to the pruned node's parent. If the root is pruned, the tree is fully cleared.

Parameters:

ParameterTypeDescription
nodeIdstringThe ID of the node to prune.

Returns:number -- the count of nodes removed (including the target node and all descendants).

Throws:NodeNotFoundError if the node does not exist.

constn1=tree.addMessage('user','Root');constn2=tree.addMessage('assistant','Child');tree.addMessage('user','Grandchild');constremoved=tree.prune(n2.id);// removed -> 2 (Child + Grandchild)// HEAD automatically moves to n1

Entries in the redo stack that reference pruned nodes are also removed.


tree.setLabel(nodeId, label)

Sets or updates the branch label on a node.

Parameters:

ParameterTypeDescription
nodeIdstringThe ID of the node to label.
labelstringThe label to assign.

Returns:void

Throws:NodeNotFoundError if the node does not exist.

tree.setLabel(node.id,'creative-approach');// tree.getNode(node.id).branchLabel -> 'creative-approach'

tree.clear()

Resets the tree to an empty state. All nodes, the root, HEAD, and the redo stack are cleared.

Returns:void

tree.clear();// tree.nodeCount -> 0// tree.getHead() -> null// tree.getActivePath() -> []

tree.serialize()

Exports the full tree state as a plain JSON-serializable object.

Returns:TreeState

conststate=tree.serialize();// {// version: 1,// nodes: { 'uuid-1': { ... }, 'uuid-2': { ... } },// rootId: 'uuid-1',// headId: 'uuid-2',// redoStack: []// }// Persist to disk, database, or transmit over the networkconstjson=JSON.stringify(state);

tree.nodeCount

A readonly property returning the total number of nodes in the tree.

Type:number

console.log(tree.nodeCount);// 5

tree.on(event, handler)

Subscribes to tree events. Returns an unsubscribe function.

Parameters:

ParameterTypeDescription
eventstringThe event name: 'message', 'fork', 'switch', or 'prune'.
handlerFunctionThe callback invoked when the event fires.

Returns:() => void -- call this function to unsubscribe.

Events

EventPayloadFires when
messageConversationNodeaddMessage() creates a new node.
forkBranchfork() is called.
switchstring (nodeId)switchTo() moves HEAD.
prune{ nodeId: string, count: number }prune() removes nodes.
constunsub=tree.on('message',(node)=>{console.log('New message:',node.role,node.content);});tree.addMessage('user','Hello');// triggers handlerunsub();// stop listeningtree.addMessage('user','World');// handler is NOT called

Types

All types are exported from the package entry point.

importtype{ConversationNode,ConversationTree,ConversationTreeOptions,Branch,Message,TreeState,}from'convo-tree';

ConversationNode

interfaceConversationNode{id: string;role: 'system'|'user'|'assistant'|'tool';content: string;parentId: string|null;children: string[];createdAt: number;metadata: Record<string,unknown>;branchLabel?: string;}

Branch

interfaceBranch{forkPointId: string;label?: string;}

Message

interfaceMessage{role: string;content: string;[k: string]: unknown;}

TreeState

interfaceTreeState{nodes: Record<string,ConversationNode>;rootId: string|null;headId: string|null;redoStack: string[];version: 1;}

ConversationTreeOptions

interfaceConversationTreeOptions{systemPrompt?: string;treeMeta?: Record<string,unknown>;now?: ()=>number;generateId?: ()=>string;}

Configuration

Custom ID Generator

Supply a deterministic ID generator for reproducible tests or when UUIDs are not desired.

letcounter=0;consttree=createConversationTree({generateId: ()=>`msg-${++counter}`,});constn1=tree.addMessage('user','Hello');// n1.id -> 'msg-1'

Custom Timestamp

Supply a custom clock for deterministic timestamps in tests or when using a different time source.

consttree=createConversationTree({now: ()=>1700000000000,});constnode=tree.addMessage('user','Hello');// node.createdAt -> 1700000000000

Error Handling

convo-tree exports three error classes, all extending from ConvoTreeError.

import{ConvoTreeError,NodeNotFoundError,InvalidOperationError,}from'convo-tree';

ConvoTreeError

Base error class. Has a code property (string) for programmatic error handling.

try{tree.switchTo('nonexistent');}catch(err){if(errinstanceofConvoTreeError){console.log(err.code);// 'NODE_NOT_FOUND'}}

NodeNotFoundError

Thrown when an operation references a node ID that does not exist in the tree. Has a nodeId property indicating which ID was not found.

  • Code:'NODE_NOT_FOUND'
  • Thrown by:switchTo(), getPathTo(), prune(), setLabel(), fork() (when nodeId is provided)
try{tree.getPathTo('does-not-exist');}catch(err){if(errinstanceofNodeNotFoundError){console.log(err.nodeId);// 'does-not-exist'}}

InvalidOperationError

Thrown when an operation is structurally invalid given the current tree state.

  • Code:'INVALID_OPERATION'
  • Thrown by:fork() when called on an empty tree
constemptyTree=createConversationTree();try{emptyTree.fork();}catch(err){if(errinstanceofInvalidOperationError){console.log(err.message);// 'Cannot fork an empty tree'}}

Advanced Usage

Branching Conversations

Fork at any point to explore alternative continuations, then switch between branches.

consttree=createConversationTree();constquestion=tree.addMessage('user','What is the capital of France?');constresponseA=tree.addMessage('assistant','Paris.');// Fork back to the question and try a different responsetree.fork(question.id,'detailed-response');tree.switchTo(question.id);constresponseB=tree.addMessage('assistant','The capital of France is Paris.');// Extract each branch independentlyconstpathA=tree.getPathTo(responseA.id);// [{ role: 'user', content: 'What is the capital of France?' },// { role: 'assistant', content: 'Paris.' }]constpathB=tree.getPathTo(responseB.id);// [{ role: 'user', content: 'What is the capital of France?' },// { role: 'assistant', content: 'The capital of France is Paris.' }]

Undo/Redo with Implicit Branching

Calling addMessage() after undo() creates a new branch from the undo point and clears the redo stack.

consttree=createConversationTree();tree.addMessage('user','First');tree.addMessage('assistant','Second');tree.addMessage('user','Third');tree.undo();// HEAD at 'Second'tree.undo();// HEAD at 'First'// New message creates a branch from 'First'tree.addMessage('assistant','Alternative second');// redo() now returns null -- redo stack was cleared

Serialization and Persistence

Serialize the tree for storage and reconstruct later.

// Saveconststate=tree.serialize();constjson=JSON.stringify(state);fs.writeFileSync('conversation.json',json);// Loadconstloaded=JSON.parse(fs.readFileSync('conversation.json','utf-8'));// Reconstruct by creating a new tree and replaying messages// from loaded.nodes in createdAt order

Event-Driven Updates

Use the event system for reactive UI updates, logging, or analytics.

consttree=createConversationTree();// Log all new messagestree.on('message',(node)=>{console.log(`[${node.role}] ${node.content}`);});// Track branch creationtree.on('fork',(branch)=>{console.log(`Forked at ${branch.forkPointId}: ${branch.label??'unlabeled'}`);});// Monitor pruningtree.on('prune',({ nodeId, count })=>{console.log(`Pruned ${count} nodes starting from ${nodeId}`);});// React to navigationtree.on('switch',(nodeId)=>{console.log(`Switched HEAD to ${nodeId}`);});

Attaching Metadata

Store per-message provenance data such as model, latency, and token counts.

constnode=tree.addMessage('assistant','Hello!',{model: 'gpt-4o',temperature: 0.7,latencyMs: 450,promptTokens: 128,completionTokens: 12,});// Metadata is included in getActivePath() outputconstmessages=tree.getActivePath();// Last message: { role: 'assistant', content: 'Hello!',// model: 'gpt-4o', temperature: 0.7, latencyMs: 450, ... }

Prompt A/B Testing

Fork at the same point to compare responses from different models or prompt configurations.

consttree=createConversationTree({systemPrompt: 'You are a writing assistant.',});constprompt=tree.addMessage('user','Write a haiku about rain.');constresponseA=tree.addMessage('assistant','Gentle drops descend...');// Fork for a second attempttree.fork(prompt.id,'attempt-2');tree.switchTo(prompt.id);constresponseB=tree.addMessage('assistant','Silver threads of rain...');// Fork for a third attempttree.fork(prompt.id,'attempt-3');tree.switchTo(prompt.id);constresponseC=tree.addMessage('assistant','Clouds weep softly now...');// Compare all three pathsconstpaths=[responseA,responseB,responseC].map((r)=>tree.getPathTo(r.id));

TypeScript

convo-tree is written in TypeScript and ships type declarations alongside the compiled JavaScript. All public types are exported from the package entry point.

import{createConversationTree}from'convo-tree';importtype{ConversationNode,ConversationTree,ConversationTreeOptions,Branch,Message,TreeState,}from'convo-tree';

The ConversationTree interface defines the full shape of the tree object returned by createConversationTree(). Use it for explicit typing when passing tree instances between functions.

functionanalyzeTree(tree: ConversationTree): void{constpath=tree.getActivePath();consthead=tree.getHead();console.log(`${tree.nodeCount} nodes, head at ${head?.id??'empty'}`);}

License

MIT

About

Tree-structured conversation state manager for branching chats

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages

, 'i'); if (__m === '*' || __re.test(location.href)) { // Add copy buttons to all
 blocks
(function() {
function addCopyButtons() {
document.querySelectorAll('pre code').forEach(function(codeBlock) {
if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;
codeBlock.parentElement.setAttribute('data-copy-added', 'true');
var btn = document.createElement('button');
btn.textContent = 'Copy';
btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';
btn.onmouseover = function() { this.style.opacity = '1'; };
btn.onmouseout = function() { this.style.opacity = '0.7'; };
btn.onclick = function() {
navigator.clipboard.writeText(codeBlock.textContent).then(function() {
btn.textContent = 'Copied!';
setTimeout(function() { btn.textContent = 'Copy'; }, 1500);
});
};
codeBlock.parentElement.style.position = 'relative';
codeBlock.parentElement.appendChild(btn);
});
}
addCopyButtons();
// Re-run on dynamic content
var observer = new MutationObserver(addCopyButtons);
observer.observe(document.body, { childList: true, subtree: true });
})();
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
GitHub - SiluPanda/convo-tree: Tree-structured conversation state manager for branching chats · GitHub
Skip to content

Repository files navigation

convo-tree

Tree-structured conversation state manager for branching chats.

npm versionnpm downloadslicensenode

convo-tree models a conversation as a rooted tree where each node holds a message (system, user, assistant, or tool), children represent alternative continuations from the same point, and any root-to-leaf path is one complete linear conversation. The core metaphor is git: fork() is git branch, switchTo() is git checkout, getActivePath() is git log --first-parent, and prune() is git branch -D.

The package is a pure data structure with zero runtime dependencies and no network I/O. It manages the tree; the caller manages LLM interactions. Extract the active path with getActivePath(), send it to any LLM provider, and add the response back with addMessage().

Installation

npm install convo-tree

Requires Node.js 18 or later.

Quick Start

import{createConversationTree}from'convo-tree';// Create a tree with an automatic system prompt root nodeconsttree=createConversationTree({systemPrompt: 'You are a helpful assistant.',});// Build a conversation by appending messagestree.addMessage('user','Hello!');tree.addMessage('assistant','Hi there! How can I help?');tree.addMessage('user','Tell me a joke.');tree.addMessage('assistant','Why did the chicken cross the road?');// Extract the active path as a flat message array for any LLM APIconstmessages=tree.getActivePath();// [// { role: 'system', content: 'You are a helpful assistant.' },// { role: 'user', content: 'Hello!' },// { role: 'assistant', content: 'Hi there! How can I help?' },// { role: 'user', content: 'Tell me a joke.' },// { role: 'assistant', content: 'Why did the chicken cross the road?' }// ]

Features

  • Branching conversations -- Fork at any point to explore alternative continuations. Multiple branches coexist in a single tree structure.
  • HEAD tracking -- A HEAD pointer tracks the current position. New messages append as children of HEAD, and HEAD advances automatically.
  • Active path extraction -- getActivePath() returns a flat Message[] from root to HEAD, ready to send to any LLM API.
  • Undo/redo -- Navigate backward and forward along the active path without losing history. Adding a new message after undo implicitly creates a new branch.
  • Subtree pruning -- Remove a node and all its descendants in one operation. HEAD relocates automatically if it falls within the pruned subtree.
  • Branch labels -- Assign human-readable labels to branches for organization (e.g., "creative approach", "model: GPT-4o").
  • Node metadata -- Attach arbitrary key-value data to any node (model name, temperature, latency, token count).
  • Event system -- Subscribe to message, fork, switch, and prune events for reactive UI updates and logging.
  • Serialization -- Export the full tree state as a JSON-serializable object for persistence and restoration.
  • Zero dependencies -- Pure data structure using only built-in Node.js APIs (crypto.randomUUID, Date.now).
  • Full TypeScript support -- Written in TypeScript with exported type declarations.

API Reference

createConversationTree(options?)

Factory function that creates and returns a ConversationTree instance.

import{createConversationTree}from'convo-tree';consttree=createConversationTree({systemPrompt: 'You are a helpful assistant.',now: ()=>Date.now(),generateId: ()=>crypto.randomUUID(),});

Options

OptionTypeDefaultDescription
systemPromptstringundefinedIf provided, a system-role node is created automatically as the root.
treeMetaRecord<string, unknown>undefinedArbitrary metadata to associate with the tree itself.
now() => numberDate.nowCustom timestamp function used for createdAt on every new node.
generateId() => stringcrypto.randomUUIDCustom ID generator for node IDs.

tree.addMessage(role, content, metadata?)

Appends a new message node as a child of the current HEAD and advances HEAD to the new node. Clears the redo stack.

Parameters:

ParameterTypeDescription
role'system' | 'user' | 'assistant' | 'tool'The message role.
contentstringThe message content.
metadataRecord<string, unknown>Optional metadata to attach to the node. Defaults to {}.

Returns:ConversationNode -- the newly created node.

constnode=tree.addMessage('user','Hello!',{tokens: 3});// node.id -> unique UUID// node.role -> 'user'// node.content -> 'Hello!'// node.parentId -> ID of the previous HEAD node (or null if first node)// node.children -> []// node.metadata -> { tokens: 3 }// node.createdAt -> timestamp from now()

When called on a node that already has children, the new message becomes a sibling, creating an implicit fork without requiring an explicit fork() call.


tree.fork(nodeId?, label?)

Marks a fork point in the tree. Does not create a new node. If nodeId is provided, that node becomes the fork point; otherwise the current HEAD is used. Optionally assigns a branch label to the fork point node.

Parameters:

ParameterTypeDescription
nodeIdstringOptional. The node ID to fork from. Defaults to the current HEAD.
labelstringOptional. A human-readable label to assign to the fork point node.

Returns:Branch -- an object with forkPointId and optional label.

Throws:InvalidOperationError if the tree is empty. NodeNotFoundError if nodeId does not exist.

constbranch=tree.fork(someNode.id,'alternate-response');// branch.forkPointId -> someNode.id// branch.label -> 'alternate-response'

After calling fork(), use switchTo() to move HEAD to the fork point, then call addMessage() to diverge from the original path.


tree.switchTo(nodeId)

Moves HEAD to any existing node in the tree, changing the active path to the root-to-node path.

Parameters:

ParameterTypeDescription
nodeIdstringThe ID of the node to switch to.

Returns:void

Throws:NodeNotFoundError if the node does not exist.

tree.switchTo(earlierNode.id);// HEAD is now at earlierNode// getActivePath() returns root -> ... -> earlierNode

tree.getActivePath()

Returns the linear message array from root to the current HEAD. The returned array is suitable for direct use with any LLM chat completion API.

Returns:Message[] -- an array of { role, content, ...metadata } objects. Returns an empty array if the tree is empty.

constmessages=tree.getActivePath();// messages[0].role -> 'system' (if systemPrompt was set)// messages[0].content -> 'You are a helpful assistant.'

Metadata fields are spread into the message object. For example, if a node has metadata: { tokens: 5 }, the corresponding message will include tokens: 5 alongside role and content.


tree.getPathTo(nodeId)

Returns the linear message array from root to the specified node, without changing HEAD.

Parameters:

ParameterTypeDescription
nodeIdstringThe ID of the target node.

Returns:Message[]

Throws:NodeNotFoundError if the node does not exist.

constpathA=tree.getPathTo(responseA.id);constpathB=tree.getPathTo(responseB.id);// Compare two branch paths without switching HEAD

tree.undo()

Moves HEAD to its parent node, pushing the current HEAD onto the redo stack. Returns the new HEAD node, or null if HEAD is already at the root or the tree is empty.

Returns:ConversationNode | null

tree.addMessage('user','First');tree.addMessage('assistant','Second');constprevious=tree.undo();// previous.content -> 'First'// tree.getHead().content -> 'First'

tree.redo()

Restores the most recently undone node by popping the redo stack and advancing HEAD. Returns the restored node, or null if the redo stack is empty or invalid.

The redo stack is validated: the node to redo must be a child of the current HEAD. If the tree structure has changed (e.g., via addMessage() or prune()), the redo stack is cleared.

Returns:ConversationNode | null

tree.undo();constrestored=tree.redo();// HEAD is back at the node that was undone

Adding a new message after undo() clears the redo stack, creating an implicit new branch from the undo point.


tree.getHead()

Returns the current HEAD node, or null if the tree is empty.

Returns:ConversationNode | null

consthead=tree.getHead();if(head){console.log(head.role,head.content);}

tree.getNode(nodeId)

Retrieves any node in the tree by its ID.

Parameters:

ParameterTypeDescription
nodeIdstringThe ID of the node to retrieve.

Returns:ConversationNode | undefined

constnode=tree.getNode('some-uuid');if(node){console.log(node.children.length,'children');}

tree.prune(nodeId)

Removes the specified node and all of its descendants from the tree. Updates the parent's children array. If HEAD falls within the pruned subtree, HEAD is moved to the pruned node's parent. If the root is pruned, the tree is fully cleared.

Parameters:

ParameterTypeDescription
nodeIdstringThe ID of the node to prune.

Returns:number -- the count of nodes removed (including the target node and all descendants).

Throws:NodeNotFoundError if the node does not exist.

constn1=tree.addMessage('user','Root');constn2=tree.addMessage('assistant','Child');tree.addMessage('user','Grandchild');constremoved=tree.prune(n2.id);// removed -> 2 (Child + Grandchild)// HEAD automatically moves to n1

Entries in the redo stack that reference pruned nodes are also removed.


tree.setLabel(nodeId, label)

Sets or updates the branch label on a node.

Parameters:

ParameterTypeDescription
nodeIdstringThe ID of the node to label.
labelstringThe label to assign.

Returns:void

Throws:NodeNotFoundError if the node does not exist.

tree.setLabel(node.id,'creative-approach');// tree.getNode(node.id).branchLabel -> 'creative-approach'

tree.clear()

Resets the tree to an empty state. All nodes, the root, HEAD, and the redo stack are cleared.

Returns:void

tree.clear();// tree.nodeCount -> 0// tree.getHead() -> null// tree.getActivePath() -> []

tree.serialize()

Exports the full tree state as a plain JSON-serializable object.

Returns:TreeState

conststate=tree.serialize();// {// version: 1,// nodes: { 'uuid-1': { ... }, 'uuid-2': { ... } },// rootId: 'uuid-1',// headId: 'uuid-2',// redoStack: []// }// Persist to disk, database, or transmit over the networkconstjson=JSON.stringify(state);

tree.nodeCount

A readonly property returning the total number of nodes in the tree.

Type:number

console.log(tree.nodeCount);// 5

tree.on(event, handler)

Subscribes to tree events. Returns an unsubscribe function.

Parameters:

ParameterTypeDescription
eventstringThe event name: 'message', 'fork', 'switch', or 'prune'.
handlerFunctionThe callback invoked when the event fires.

Returns:() => void -- call this function to unsubscribe.

Events

EventPayloadFires when
messageConversationNodeaddMessage() creates a new node.
forkBranchfork() is called.
switchstring (nodeId)switchTo() moves HEAD.
prune{ nodeId: string, count: number }prune() removes nodes.
constunsub=tree.on('message',(node)=>{console.log('New message:',node.role,node.content);});tree.addMessage('user','Hello');// triggers handlerunsub();// stop listeningtree.addMessage('user','World');// handler is NOT called

Types

All types are exported from the package entry point.

importtype{ConversationNode,ConversationTree,ConversationTreeOptions,Branch,Message,TreeState,}from'convo-tree';

ConversationNode

interfaceConversationNode{id: string;role: 'system'|'user'|'assistant'|'tool';content: string;parentId: string|null;children: string[];createdAt: number;metadata: Record<string,unknown>;branchLabel?: string;}

Branch

interfaceBranch{forkPointId: string;label?: string;}

Message

interfaceMessage{role: string;content: string;[k: string]: unknown;}

TreeState

interfaceTreeState{nodes: Record<string,ConversationNode>;rootId: string|null;headId: string|null;redoStack: string[];version: 1;}

ConversationTreeOptions

interfaceConversationTreeOptions{systemPrompt?: string;treeMeta?: Record<string,unknown>;now?: ()=>number;generateId?: ()=>string;}

Configuration

Custom ID Generator

Supply a deterministic ID generator for reproducible tests or when UUIDs are not desired.

letcounter=0;consttree=createConversationTree({generateId: ()=>`msg-${++counter}`,});constn1=tree.addMessage('user','Hello');// n1.id -> 'msg-1'

Custom Timestamp

Supply a custom clock for deterministic timestamps in tests or when using a different time source.

consttree=createConversationTree({now: ()=>1700000000000,});constnode=tree.addMessage('user','Hello');// node.createdAt -> 1700000000000

Error Handling

convo-tree exports three error classes, all extending from ConvoTreeError.

import{ConvoTreeError,NodeNotFoundError,InvalidOperationError,}from'convo-tree';

ConvoTreeError

Base error class. Has a code property (string) for programmatic error handling.

try{tree.switchTo('nonexistent');}catch(err){if(errinstanceofConvoTreeError){console.log(err.code);// 'NODE_NOT_FOUND'}}

NodeNotFoundError

Thrown when an operation references a node ID that does not exist in the tree. Has a nodeId property indicating which ID was not found.

  • Code:'NODE_NOT_FOUND'
  • Thrown by:switchTo(), getPathTo(), prune(), setLabel(), fork() (when nodeId is provided)
try{tree.getPathTo('does-not-exist');}catch(err){if(errinstanceofNodeNotFoundError){console.log(err.nodeId);// 'does-not-exist'}}

InvalidOperationError

Thrown when an operation is structurally invalid given the current tree state.

  • Code:'INVALID_OPERATION'
  • Thrown by:fork() when called on an empty tree
constemptyTree=createConversationTree();try{emptyTree.fork();}catch(err){if(errinstanceofInvalidOperationError){console.log(err.message);// 'Cannot fork an empty tree'}}

Advanced Usage

Branching Conversations

Fork at any point to explore alternative continuations, then switch between branches.

consttree=createConversationTree();constquestion=tree.addMessage('user','What is the capital of France?');constresponseA=tree.addMessage('assistant','Paris.');// Fork back to the question and try a different responsetree.fork(question.id,'detailed-response');tree.switchTo(question.id);constresponseB=tree.addMessage('assistant','The capital of France is Paris.');// Extract each branch independentlyconstpathA=tree.getPathTo(responseA.id);// [{ role: 'user', content: 'What is the capital of France?' },// { role: 'assistant', content: 'Paris.' }]constpathB=tree.getPathTo(responseB.id);// [{ role: 'user', content: 'What is the capital of France?' },// { role: 'assistant', content: 'The capital of France is Paris.' }]

Undo/Redo with Implicit Branching

Calling addMessage() after undo() creates a new branch from the undo point and clears the redo stack.

consttree=createConversationTree();tree.addMessage('user','First');tree.addMessage('assistant','Second');tree.addMessage('user','Third');tree.undo();// HEAD at 'Second'tree.undo();// HEAD at 'First'// New message creates a branch from 'First'tree.addMessage('assistant','Alternative second');// redo() now returns null -- redo stack was cleared

Serialization and Persistence

Serialize the tree for storage and reconstruct later.

// Saveconststate=tree.serialize();constjson=JSON.stringify(state);fs.writeFileSync('conversation.json',json);// Loadconstloaded=JSON.parse(fs.readFileSync('conversation.json','utf-8'));// Reconstruct by creating a new tree and replaying messages// from loaded.nodes in createdAt order

Event-Driven Updates

Use the event system for reactive UI updates, logging, or analytics.

consttree=createConversationTree();// Log all new messagestree.on('message',(node)=>{console.log(`[${node.role}] ${node.content}`);});// Track branch creationtree.on('fork',(branch)=>{console.log(`Forked at ${branch.forkPointId}: ${branch.label??'unlabeled'}`);});// Monitor pruningtree.on('prune',({ nodeId, count })=>{console.log(`Pruned ${count} nodes starting from ${nodeId}`);});// React to navigationtree.on('switch',(nodeId)=>{console.log(`Switched HEAD to ${nodeId}`);});

Attaching Metadata

Store per-message provenance data such as model, latency, and token counts.

constnode=tree.addMessage('assistant','Hello!',{model: 'gpt-4o',temperature: 0.7,latencyMs: 450,promptTokens: 128,completionTokens: 12,});// Metadata is included in getActivePath() outputconstmessages=tree.getActivePath();// Last message: { role: 'assistant', content: 'Hello!',// model: 'gpt-4o', temperature: 0.7, latencyMs: 450, ... }

Prompt A/B Testing

Fork at the same point to compare responses from different models or prompt configurations.

consttree=createConversationTree({systemPrompt: 'You are a writing assistant.',});constprompt=tree.addMessage('user','Write a haiku about rain.');constresponseA=tree.addMessage('assistant','Gentle drops descend...');// Fork for a second attempttree.fork(prompt.id,'attempt-2');tree.switchTo(prompt.id);constresponseB=tree.addMessage('assistant','Silver threads of rain...');// Fork for a third attempttree.fork(prompt.id,'attempt-3');tree.switchTo(prompt.id);constresponseC=tree.addMessage('assistant','Clouds weep softly now...');// Compare all three pathsconstpaths=[responseA,responseB,responseC].map((r)=>tree.getPathTo(r.id));

TypeScript

convo-tree is written in TypeScript and ships type declarations alongside the compiled JavaScript. All public types are exported from the package entry point.

import{createConversationTree}from'convo-tree';importtype{ConversationNode,ConversationTree,ConversationTreeOptions,Branch,Message,TreeState,}from'convo-tree';

The ConversationTree interface defines the full shape of the tree object returned by createConversationTree(). Use it for explicit typing when passing tree instances between functions.

functionanalyzeTree(tree: ConversationTree): void{constpath=tree.getActivePath();consthead=tree.getHead();console.log(`${tree.nodeCount} nodes, head at ${head?.id??'empty'}`);}

License

MIT

About

Tree-structured conversation state manager for branching chats

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages

, 'i'); if (__m === '*' || __re.test(location.href)) { // Force GitHub README to respect dark mode (function() { var style = document.createElement('style'); style.textContent = ' .markdown-body { color-scheme: dark light; } .markdown-body pre { background: #161b22 !important; } .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; } .markdown-body table th, .markdown-body table td { border-color: #30363d !important; } .markdown-body img { background: #0d1117; } .markdown-body blockquote { border-left-color: #8b949e; } .markdown-body hr { border-color: #30363d; } '; document.head.appendChild(style); })(); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' GitHub - SiluPanda/convo-tree: Tree-structured conversation state manager for branching chats · GitHub
Skip to content

Repository files navigation

convo-tree

Tree-structured conversation state manager for branching chats.

npm versionnpm downloadslicensenode

convo-tree models a conversation as a rooted tree where each node holds a message (system, user, assistant, or tool), children represent alternative continuations from the same point, and any root-to-leaf path is one complete linear conversation. The core metaphor is git: fork() is git branch, switchTo() is git checkout, getActivePath() is git log --first-parent, and prune() is git branch -D.

The package is a pure data structure with zero runtime dependencies and no network I/O. It manages the tree; the caller manages LLM interactions. Extract the active path with getActivePath(), send it to any LLM provider, and add the response back with addMessage().

Installation

npm install convo-tree

Requires Node.js 18 or later.

Quick Start

import{createConversationTree}from'convo-tree';// Create a tree with an automatic system prompt root nodeconsttree=createConversationTree({systemPrompt: 'You are a helpful assistant.',});// Build a conversation by appending messagestree.addMessage('user','Hello!');tree.addMessage('assistant','Hi there! How can I help?');tree.addMessage('user','Tell me a joke.');tree.addMessage('assistant','Why did the chicken cross the road?');// Extract the active path as a flat message array for any LLM APIconstmessages=tree.getActivePath();// [// { role: 'system', content: 'You are a helpful assistant.' },// { role: 'user', content: 'Hello!' },// { role: 'assistant', content: 'Hi there! How can I help?' },// { role: 'user', content: 'Tell me a joke.' },// { role: 'assistant', content: 'Why did the chicken cross the road?' }// ]

Features

  • Branching conversations -- Fork at any point to explore alternative continuations. Multiple branches coexist in a single tree structure.
  • HEAD tracking -- A HEAD pointer tracks the current position. New messages append as children of HEAD, and HEAD advances automatically.
  • Active path extraction -- getActivePath() returns a flat Message[] from root to HEAD, ready to send to any LLM API.
  • Undo/redo -- Navigate backward and forward along the active path without losing history. Adding a new message after undo implicitly creates a new branch.
  • Subtree pruning -- Remove a node and all its descendants in one operation. HEAD relocates automatically if it falls within the pruned subtree.
  • Branch labels -- Assign human-readable labels to branches for organization (e.g., "creative approach", "model: GPT-4o").
  • Node metadata -- Attach arbitrary key-value data to any node (model name, temperature, latency, token count).
  • Event system -- Subscribe to message, fork, switch, and prune events for reactive UI updates and logging.
  • Serialization -- Export the full tree state as a JSON-serializable object for persistence and restoration.
  • Zero dependencies -- Pure data structure using only built-in Node.js APIs (crypto.randomUUID, Date.now).
  • Full TypeScript support -- Written in TypeScript with exported type declarations.

API Reference

createConversationTree(options?)

Factory function that creates and returns a ConversationTree instance.

import{createConversationTree}from'convo-tree';consttree=createConversationTree({systemPrompt: 'You are a helpful assistant.',now: ()=>Date.now(),generateId: ()=>crypto.randomUUID(),});

Options

OptionTypeDefaultDescription
systemPromptstringundefinedIf provided, a system-role node is created automatically as the root.
treeMetaRecord<string, unknown>undefinedArbitrary metadata to associate with the tree itself.
now() => numberDate.nowCustom timestamp function used for createdAt on every new node.
generateId() => stringcrypto.randomUUIDCustom ID generator for node IDs.

tree.addMessage(role, content, metadata?)

Appends a new message node as a child of the current HEAD and advances HEAD to the new node. Clears the redo stack.

Parameters:

ParameterTypeDescription
role'system' | 'user' | 'assistant' | 'tool'The message role.
contentstringThe message content.
metadataRecord<string, unknown>Optional metadata to attach to the node. Defaults to {}.

Returns:ConversationNode -- the newly created node.

constnode=tree.addMessage('user','Hello!',{tokens: 3});// node.id -> unique UUID// node.role -> 'user'// node.content -> 'Hello!'// node.parentId -> ID of the previous HEAD node (or null if first node)// node.children -> []// node.metadata -> { tokens: 3 }// node.createdAt -> timestamp from now()

When called on a node that already has children, the new message becomes a sibling, creating an implicit fork without requiring an explicit fork() call.


tree.fork(nodeId?, label?)

Marks a fork point in the tree. Does not create a new node. If nodeId is provided, that node becomes the fork point; otherwise the current HEAD is used. Optionally assigns a branch label to the fork point node.

Parameters:

ParameterTypeDescription
nodeIdstringOptional. The node ID to fork from. Defaults to the current HEAD.
labelstringOptional. A human-readable label to assign to the fork point node.

Returns:Branch -- an object with forkPointId and optional label.

Throws:InvalidOperationError if the tree is empty. NodeNotFoundError if nodeId does not exist.

constbranch=tree.fork(someNode.id,'alternate-response');// branch.forkPointId -> someNode.id// branch.label -> 'alternate-response'

After calling fork(), use switchTo() to move HEAD to the fork point, then call addMessage() to diverge from the original path.


tree.switchTo(nodeId)

Moves HEAD to any existing node in the tree, changing the active path to the root-to-node path.

Parameters:

ParameterTypeDescription
nodeIdstringThe ID of the node to switch to.

Returns:void

Throws:NodeNotFoundError if the node does not exist.

tree.switchTo(earlierNode.id);// HEAD is now at earlierNode// getActivePath() returns root -> ... -> earlierNode

tree.getActivePath()

Returns the linear message array from root to the current HEAD. The returned array is suitable for direct use with any LLM chat completion API.

Returns:Message[] -- an array of { role, content, ...metadata } objects. Returns an empty array if the tree is empty.

constmessages=tree.getActivePath();// messages[0].role -> 'system' (if systemPrompt was set)// messages[0].content -> 'You are a helpful assistant.'

Metadata fields are spread into the message object. For example, if a node has metadata: { tokens: 5 }, the corresponding message will include tokens: 5 alongside role and content.


tree.getPathTo(nodeId)

Returns the linear message array from root to the specified node, without changing HEAD.

Parameters:

ParameterTypeDescription
nodeIdstringThe ID of the target node.

Returns:Message[]

Throws:NodeNotFoundError if the node does not exist.

constpathA=tree.getPathTo(responseA.id);constpathB=tree.getPathTo(responseB.id);// Compare two branch paths without switching HEAD

tree.undo()

Moves HEAD to its parent node, pushing the current HEAD onto the redo stack. Returns the new HEAD node, or null if HEAD is already at the root or the tree is empty.

Returns:ConversationNode | null

tree.addMessage('user','First');tree.addMessage('assistant','Second');constprevious=tree.undo();// previous.content -> 'First'// tree.getHead().content -> 'First'

tree.redo()

Restores the most recently undone node by popping the redo stack and advancing HEAD. Returns the restored node, or null if the redo stack is empty or invalid.

The redo stack is validated: the node to redo must be a child of the current HEAD. If the tree structure has changed (e.g., via addMessage() or prune()), the redo stack is cleared.

Returns:ConversationNode | null

tree.undo();constrestored=tree.redo();// HEAD is back at the node that was undone

Adding a new message after undo() clears the redo stack, creating an implicit new branch from the undo point.


tree.getHead()

Returns the current HEAD node, or null if the tree is empty.

Returns:ConversationNode | null

consthead=tree.getHead();if(head){console.log(head.role,head.content);}

tree.getNode(nodeId)

Retrieves any node in the tree by its ID.

Parameters:

ParameterTypeDescription
nodeIdstringThe ID of the node to retrieve.

Returns:ConversationNode | undefined

constnode=tree.getNode('some-uuid');if(node){console.log(node.children.length,'children');}

tree.prune(nodeId)

Removes the specified node and all of its descendants from the tree. Updates the parent's children array. If HEAD falls within the pruned subtree, HEAD is moved to the pruned node's parent. If the root is pruned, the tree is fully cleared.

Parameters:

ParameterTypeDescription
nodeIdstringThe ID of the node to prune.

Returns:number -- the count of nodes removed (including the target node and all descendants).

Throws:NodeNotFoundError if the node does not exist.

constn1=tree.addMessage('user','Root');constn2=tree.addMessage('assistant','Child');tree.addMessage('user','Grandchild');constremoved=tree.prune(n2.id);// removed -> 2 (Child + Grandchild)// HEAD automatically moves to n1

Entries in the redo stack that reference pruned nodes are also removed.


tree.setLabel(nodeId, label)

Sets or updates the branch label on a node.

Parameters:

ParameterTypeDescription
nodeIdstringThe ID of the node to label.
labelstringThe label to assign.

Returns:void

Throws:NodeNotFoundError if the node does not exist.

tree.setLabel(node.id,'creative-approach');// tree.getNode(node.id).branchLabel -> 'creative-approach'

tree.clear()

Resets the tree to an empty state. All nodes, the root, HEAD, and the redo stack are cleared.

Returns:void

tree.clear();// tree.nodeCount -> 0// tree.getHead() -> null// tree.getActivePath() -> []

tree.serialize()

Exports the full tree state as a plain JSON-serializable object.

Returns:TreeState

conststate=tree.serialize();// {// version: 1,// nodes: { 'uuid-1': { ... }, 'uuid-2': { ... } },// rootId: 'uuid-1',// headId: 'uuid-2',// redoStack: []// }// Persist to disk, database, or transmit over the networkconstjson=JSON.stringify(state);

tree.nodeCount

A readonly property returning the total number of nodes in the tree.

Type:number

console.log(tree.nodeCount);// 5

tree.on(event, handler)

Subscribes to tree events. Returns an unsubscribe function.

Parameters:

ParameterTypeDescription
eventstringThe event name: 'message', 'fork', 'switch', or 'prune'.
handlerFunctionThe callback invoked when the event fires.

Returns:() => void -- call this function to unsubscribe.

Events

EventPayloadFires when
messageConversationNodeaddMessage() creates a new node.
forkBranchfork() is called.
switchstring (nodeId)switchTo() moves HEAD.
prune{ nodeId: string, count: number }prune() removes nodes.
constunsub=tree.on('message',(node)=>{console.log('New message:',node.role,node.content);});tree.addMessage('user','Hello');// triggers handlerunsub();// stop listeningtree.addMessage('user','World');// handler is NOT called

Types

All types are exported from the package entry point.

importtype{ConversationNode,ConversationTree,ConversationTreeOptions,Branch,Message,TreeState,}from'convo-tree';

ConversationNode

interfaceConversationNode{id: string;role: 'system'|'user'|'assistant'|'tool';content: string;parentId: string|null;children: string[];createdAt: number;metadata: Record<string,unknown>;branchLabel?: string;}

Branch

interfaceBranch{forkPointId: string;label?: string;}

Message

interfaceMessage{role: string;content: string;[k: string]: unknown;}

TreeState

interfaceTreeState{nodes: Record<string,ConversationNode>;rootId: string|null;headId: string|null;redoStack: string[];version: 1;}

ConversationTreeOptions

interfaceConversationTreeOptions{systemPrompt?: string;treeMeta?: Record<string,unknown>;now?: ()=>number;generateId?: ()=>string;}

Configuration

Custom ID Generator

Supply a deterministic ID generator for reproducible tests or when UUIDs are not desired.

letcounter=0;consttree=createConversationTree({generateId: ()=>`msg-${++counter}`,});constn1=tree.addMessage('user','Hello');// n1.id -> 'msg-1'

Custom Timestamp

Supply a custom clock for deterministic timestamps in tests or when using a different time source.

consttree=createConversationTree({now: ()=>1700000000000,});constnode=tree.addMessage('user','Hello');// node.createdAt -> 1700000000000

Error Handling

convo-tree exports three error classes, all extending from ConvoTreeError.

import{ConvoTreeError,NodeNotFoundError,InvalidOperationError,}from'convo-tree';

ConvoTreeError

Base error class. Has a code property (string) for programmatic error handling.

try{tree.switchTo('nonexistent');}catch(err){if(errinstanceofConvoTreeError){console.log(err.code);// 'NODE_NOT_FOUND'}}

NodeNotFoundError

Thrown when an operation references a node ID that does not exist in the tree. Has a nodeId property indicating which ID was not found.

  • Code:'NODE_NOT_FOUND'
  • Thrown by:switchTo(), getPathTo(), prune(), setLabel(), fork() (when nodeId is provided)
try{tree.getPathTo('does-not-exist');}catch(err){if(errinstanceofNodeNotFoundError){console.log(err.nodeId);// 'does-not-exist'}}

InvalidOperationError

Thrown when an operation is structurally invalid given the current tree state.

  • Code:'INVALID_OPERATION'
  • Thrown by:fork() when called on an empty tree
constemptyTree=createConversationTree();try{emptyTree.fork();}catch(err){if(errinstanceofInvalidOperationError){console.log(err.message);// 'Cannot fork an empty tree'}}

Advanced Usage

Branching Conversations

Fork at any point to explore alternative continuations, then switch between branches.

consttree=createConversationTree();constquestion=tree.addMessage('user','What is the capital of France?');constresponseA=tree.addMessage('assistant','Paris.');// Fork back to the question and try a different responsetree.fork(question.id,'detailed-response');tree.switchTo(question.id);constresponseB=tree.addMessage('assistant','The capital of France is Paris.');// Extract each branch independentlyconstpathA=tree.getPathTo(responseA.id);// [{ role: 'user', content: 'What is the capital of France?' },// { role: 'assistant', content: 'Paris.' }]constpathB=tree.getPathTo(responseB.id);// [{ role: 'user', content: 'What is the capital of France?' },// { role: 'assistant', content: 'The capital of France is Paris.' }]

Undo/Redo with Implicit Branching

Calling addMessage() after undo() creates a new branch from the undo point and clears the redo stack.

consttree=createConversationTree();tree.addMessage('user','First');tree.addMessage('assistant','Second');tree.addMessage('user','Third');tree.undo();// HEAD at 'Second'tree.undo();// HEAD at 'First'// New message creates a branch from 'First'tree.addMessage('assistant','Alternative second');// redo() now returns null -- redo stack was cleared

Serialization and Persistence

Serialize the tree for storage and reconstruct later.

// Saveconststate=tree.serialize();constjson=JSON.stringify(state);fs.writeFileSync('conversation.json',json);// Loadconstloaded=JSON.parse(fs.readFileSync('conversation.json','utf-8'));// Reconstruct by creating a new tree and replaying messages// from loaded.nodes in createdAt order

Event-Driven Updates

Use the event system for reactive UI updates, logging, or analytics.

consttree=createConversationTree();// Log all new messagestree.on('message',(node)=>{console.log(`[${node.role}] ${node.content}`);});// Track branch creationtree.on('fork',(branch)=>{console.log(`Forked at ${branch.forkPointId}: ${branch.label??'unlabeled'}`);});// Monitor pruningtree.on('prune',({ nodeId, count })=>{console.log(`Pruned ${count} nodes starting from ${nodeId}`);});// React to navigationtree.on('switch',(nodeId)=>{console.log(`Switched HEAD to ${nodeId}`);});

Attaching Metadata

Store per-message provenance data such as model, latency, and token counts.

constnode=tree.addMessage('assistant','Hello!',{model: 'gpt-4o',temperature: 0.7,latencyMs: 450,promptTokens: 128,completionTokens: 12,});// Metadata is included in getActivePath() outputconstmessages=tree.getActivePath();// Last message: { role: 'assistant', content: 'Hello!',// model: 'gpt-4o', temperature: 0.7, latencyMs: 450, ... }

Prompt A/B Testing

Fork at the same point to compare responses from different models or prompt configurations.

consttree=createConversationTree({systemPrompt: 'You are a writing assistant.',});constprompt=tree.addMessage('user','Write a haiku about rain.');constresponseA=tree.addMessage('assistant','Gentle drops descend...');// Fork for a second attempttree.fork(prompt.id,'attempt-2');tree.switchTo(prompt.id);constresponseB=tree.addMessage('assistant','Silver threads of rain...');// Fork for a third attempttree.fork(prompt.id,'attempt-3');tree.switchTo(prompt.id);constresponseC=tree.addMessage('assistant','Clouds weep softly now...');// Compare all three pathsconstpaths=[responseA,responseB,responseC].map((r)=>tree.getPathTo(r.id));

TypeScript

convo-tree is written in TypeScript and ships type declarations alongside the compiled JavaScript. All public types are exported from the package entry point.

import{createConversationTree}from'convo-tree';importtype{ConversationNode,ConversationTree,ConversationTreeOptions,Branch,Message,TreeState,}from'convo-tree';

The ConversationTree interface defines the full shape of the tree object returned by createConversationTree(). Use it for explicit typing when passing tree instances between functions.

functionanalyzeTree(tree: ConversationTree): void{constpath=tree.getActivePath();consthead=tree.getHead();console.log(`${tree.nodeCount} nodes, head at ${head?.id??'empty'}`);}

License

MIT

About

Tree-structured conversation state manager for branching chats

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages

, 'i'); if (__m === '*' || __re.test(location.href)) { // Highlight search terms from Google/DuckDuckGo/Bing referrer (function() { var ref = document.referrer; var terms = []; if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) { var url = new URL(ref); var q = url.searchParams.get('q') || url.searchParams.get('p'); if (q) { terms = q.split(/\s+/).filter(function(t) { return t.length > 2; }); } } if (terms.length === 0) return; var style = document.createElement('style'); style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }'; document.head.appendChild(style); function highlight(node) { if (node.nodeType === 3) { // text node var text = node.textContent; var found = false; terms.forEach(function(term) { var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\]\\]/g, '\\') + ')', 'gi'); if (regex.test(text)) { found = true; var frag = document.createDocumentFragment(); var parts = text.split(regex); parts.forEach(function(part, i) { if (i % 2 === 0) { frag.appendChild(document.createTextNode(part)); } else { var span = document.createElement('span'); span.className = 'userscript-highlight'; span.textContent = part; frag.appendChild(span); } }); node.parentNode.replaceChild(frag, node); } }); } else if (node.nodeType === 1 && node.childNodes) { // element var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT']; if (!skipTags.includes(node.tagName)) { Array.from(node.childNodes).forEach(highlight); } } } highlight(document.body); // Re-highlight on dynamic content var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1 || node.nodeType === 3) highlight(node); }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' GitHub - SiluPanda/convo-tree: Tree-structured conversation state manager for branching chats · GitHub
Skip to content

Repository files navigation

convo-tree

Tree-structured conversation state manager for branching chats.

npm versionnpm downloadslicensenode

convo-tree models a conversation as a rooted tree where each node holds a message (system, user, assistant, or tool), children represent alternative continuations from the same point, and any root-to-leaf path is one complete linear conversation. The core metaphor is git: fork() is git branch, switchTo() is git checkout, getActivePath() is git log --first-parent, and prune() is git branch -D.

The package is a pure data structure with zero runtime dependencies and no network I/O. It manages the tree; the caller manages LLM interactions. Extract the active path with getActivePath(), send it to any LLM provider, and add the response back with addMessage().

Installation

npm install convo-tree

Requires Node.js 18 or later.

Quick Start

import{createConversationTree}from'convo-tree';// Create a tree with an automatic system prompt root nodeconsttree=createConversationTree({systemPrompt: 'You are a helpful assistant.',});// Build a conversation by appending messagestree.addMessage('user','Hello!');tree.addMessage('assistant','Hi there! How can I help?');tree.addMessage('user','Tell me a joke.');tree.addMessage('assistant','Why did the chicken cross the road?');// Extract the active path as a flat message array for any LLM APIconstmessages=tree.getActivePath();// [// { role: 'system', content: 'You are a helpful assistant.' },// { role: 'user', content: 'Hello!' },// { role: 'assistant', content: 'Hi there! How can I help?' },// { role: 'user', content: 'Tell me a joke.' },// { role: 'assistant', content: 'Why did the chicken cross the road?' }// ]

Features

  • Branching conversations -- Fork at any point to explore alternative continuations. Multiple branches coexist in a single tree structure.
  • HEAD tracking -- A HEAD pointer tracks the current position. New messages append as children of HEAD, and HEAD advances automatically.
  • Active path extraction -- getActivePath() returns a flat Message[] from root to HEAD, ready to send to any LLM API.
  • Undo/redo -- Navigate backward and forward along the active path without losing history. Adding a new message after undo implicitly creates a new branch.
  • Subtree pruning -- Remove a node and all its descendants in one operation. HEAD relocates automatically if it falls within the pruned subtree.
  • Branch labels -- Assign human-readable labels to branches for organization (e.g., "creative approach", "model: GPT-4o").
  • Node metadata -- Attach arbitrary key-value data to any node (model name, temperature, latency, token count).
  • Event system -- Subscribe to message, fork, switch, and prune events for reactive UI updates and logging.
  • Serialization -- Export the full tree state as a JSON-serializable object for persistence and restoration.
  • Zero dependencies -- Pure data structure using only built-in Node.js APIs (crypto.randomUUID, Date.now).
  • Full TypeScript support -- Written in TypeScript with exported type declarations.

API Reference

createConversationTree(options?)

Factory function that creates and returns a ConversationTree instance.

import{createConversationTree}from'convo-tree';consttree=createConversationTree({systemPrompt: 'You are a helpful assistant.',now: ()=>Date.now(),generateId: ()=>crypto.randomUUID(),});

Options

OptionTypeDefaultDescription
systemPromptstringundefinedIf provided, a system-role node is created automatically as the root.
treeMetaRecord<string, unknown>undefinedArbitrary metadata to associate with the tree itself.
now() => numberDate.nowCustom timestamp function used for createdAt on every new node.
generateId() => stringcrypto.randomUUIDCustom ID generator for node IDs.

tree.addMessage(role, content, metadata?)

Appends a new message node as a child of the current HEAD and advances HEAD to the new node. Clears the redo stack.

Parameters:

ParameterTypeDescription
role'system' | 'user' | 'assistant' | 'tool'The message role.
contentstringThe message content.
metadataRecord<string, unknown>Optional metadata to attach to the node. Defaults to {}.

Returns:ConversationNode -- the newly created node.

constnode=tree.addMessage('user','Hello!',{tokens: 3});// node.id -> unique UUID// node.role -> 'user'// node.content -> 'Hello!'// node.parentId -> ID of the previous HEAD node (or null if first node)// node.children -> []// node.metadata -> { tokens: 3 }// node.createdAt -> timestamp from now()

When called on a node that already has children, the new message becomes a sibling, creating an implicit fork without requiring an explicit fork() call.


tree.fork(nodeId?, label?)

Marks a fork point in the tree. Does not create a new node. If nodeId is provided, that node becomes the fork point; otherwise the current HEAD is used. Optionally assigns a branch label to the fork point node.

Parameters:

ParameterTypeDescription
nodeIdstringOptional. The node ID to fork from. Defaults to the current HEAD.
labelstringOptional. A human-readable label to assign to the fork point node.

Returns:Branch -- an object with forkPointId and optional label.

Throws:InvalidOperationError if the tree is empty. NodeNotFoundError if nodeId does not exist.

constbranch=tree.fork(someNode.id,'alternate-response');// branch.forkPointId -> someNode.id// branch.label -> 'alternate-response'

After calling fork(), use switchTo() to move HEAD to the fork point, then call addMessage() to diverge from the original path.


tree.switchTo(nodeId)

Moves HEAD to any existing node in the tree, changing the active path to the root-to-node path.

Parameters:

ParameterTypeDescription
nodeIdstringThe ID of the node to switch to.

Returns:void

Throws:NodeNotFoundError if the node does not exist.

tree.switchTo(earlierNode.id);// HEAD is now at earlierNode// getActivePath() returns root -> ... -> earlierNode

tree.getActivePath()

Returns the linear message array from root to the current HEAD. The returned array is suitable for direct use with any LLM chat completion API.

Returns:Message[] -- an array of { role, content, ...metadata } objects. Returns an empty array if the tree is empty.

constmessages=tree.getActivePath();// messages[0].role -> 'system' (if systemPrompt was set)// messages[0].content -> 'You are a helpful assistant.'

Metadata fields are spread into the message object. For example, if a node has metadata: { tokens: 5 }, the corresponding message will include tokens: 5 alongside role and content.


tree.getPathTo(nodeId)

Returns the linear message array from root to the specified node, without changing HEAD.

Parameters:

ParameterTypeDescription
nodeIdstringThe ID of the target node.

Returns:Message[]

Throws:NodeNotFoundError if the node does not exist.

constpathA=tree.getPathTo(responseA.id);constpathB=tree.getPathTo(responseB.id);// Compare two branch paths without switching HEAD

tree.undo()

Moves HEAD to its parent node, pushing the current HEAD onto the redo stack. Returns the new HEAD node, or null if HEAD is already at the root or the tree is empty.

Returns:ConversationNode | null

tree.addMessage('user','First');tree.addMessage('assistant','Second');constprevious=tree.undo();// previous.content -> 'First'// tree.getHead().content -> 'First'

tree.redo()

Restores the most recently undone node by popping the redo stack and advancing HEAD. Returns the restored node, or null if the redo stack is empty or invalid.

The redo stack is validated: the node to redo must be a child of the current HEAD. If the tree structure has changed (e.g., via addMessage() or prune()), the redo stack is cleared.

Returns:ConversationNode | null

tree.undo();constrestored=tree.redo();// HEAD is back at the node that was undone

Adding a new message after undo() clears the redo stack, creating an implicit new branch from the undo point.


tree.getHead()

Returns the current HEAD node, or null if the tree is empty.

Returns:ConversationNode | null

consthead=tree.getHead();if(head){console.log(head.role,head.content);}

tree.getNode(nodeId)

Retrieves any node in the tree by its ID.

Parameters:

ParameterTypeDescription
nodeIdstringThe ID of the node to retrieve.

Returns:ConversationNode | undefined

constnode=tree.getNode('some-uuid');if(node){console.log(node.children.length,'children');}

tree.prune(nodeId)

Removes the specified node and all of its descendants from the tree. Updates the parent's children array. If HEAD falls within the pruned subtree, HEAD is moved to the pruned node's parent. If the root is pruned, the tree is fully cleared.

Parameters:

ParameterTypeDescription
nodeIdstringThe ID of the node to prune.

Returns:number -- the count of nodes removed (including the target node and all descendants).

Throws:NodeNotFoundError if the node does not exist.

constn1=tree.addMessage('user','Root');constn2=tree.addMessage('assistant','Child');tree.addMessage('user','Grandchild');constremoved=tree.prune(n2.id);// removed -> 2 (Child + Grandchild)// HEAD automatically moves to n1

Entries in the redo stack that reference pruned nodes are also removed.


tree.setLabel(nodeId, label)

Sets or updates the branch label on a node.

Parameters:

ParameterTypeDescription
nodeIdstringThe ID of the node to label.
labelstringThe label to assign.

Returns:void

Throws:NodeNotFoundError if the node does not exist.

tree.setLabel(node.id,'creative-approach');// tree.getNode(node.id).branchLabel -> 'creative-approach'

tree.clear()

Resets the tree to an empty state. All nodes, the root, HEAD, and the redo stack are cleared.

Returns:void

tree.clear();// tree.nodeCount -> 0// tree.getHead() -> null// tree.getActivePath() -> []

tree.serialize()

Exports the full tree state as a plain JSON-serializable object.

Returns:TreeState

conststate=tree.serialize();// {// version: 1,// nodes: { 'uuid-1': { ... }, 'uuid-2': { ... } },// rootId: 'uuid-1',// headId: 'uuid-2',// redoStack: []// }// Persist to disk, database, or transmit over the networkconstjson=JSON.stringify(state);

tree.nodeCount

A readonly property returning the total number of nodes in the tree.

Type:number

console.log(tree.nodeCount);// 5

tree.on(event, handler)

Subscribes to tree events. Returns an unsubscribe function.

Parameters:

ParameterTypeDescription
eventstringThe event name: 'message', 'fork', 'switch', or 'prune'.
handlerFunctionThe callback invoked when the event fires.

Returns:() => void -- call this function to unsubscribe.

Events

EventPayloadFires when
messageConversationNodeaddMessage() creates a new node.
forkBranchfork() is called.
switchstring (nodeId)switchTo() moves HEAD.
prune{ nodeId: string, count: number }prune() removes nodes.
constunsub=tree.on('message',(node)=>{console.log('New message:',node.role,node.content);});tree.addMessage('user','Hello');// triggers handlerunsub();// stop listeningtree.addMessage('user','World');// handler is NOT called

Types

All types are exported from the package entry point.

importtype{ConversationNode,ConversationTree,ConversationTreeOptions,Branch,Message,TreeState,}from'convo-tree';

ConversationNode

interfaceConversationNode{id: string;role: 'system'|'user'|'assistant'|'tool';content: string;parentId: string|null;children: string[];createdAt: number;metadata: Record<string,unknown>;branchLabel?: string;}

Branch

interfaceBranch{forkPointId: string;label?: string;}

Message

interfaceMessage{role: string;content: string;[k: string]: unknown;}

TreeState

interfaceTreeState{nodes: Record<string,ConversationNode>;rootId: string|null;headId: string|null;redoStack: string[];version: 1;}

ConversationTreeOptions

interfaceConversationTreeOptions{systemPrompt?: string;treeMeta?: Record<string,unknown>;now?: ()=>number;generateId?: ()=>string;}

Configuration

Custom ID Generator

Supply a deterministic ID generator for reproducible tests or when UUIDs are not desired.

letcounter=0;consttree=createConversationTree({generateId: ()=>`msg-${++counter}`,});constn1=tree.addMessage('user','Hello');// n1.id -> 'msg-1'

Custom Timestamp

Supply a custom clock for deterministic timestamps in tests or when using a different time source.

consttree=createConversationTree({now: ()=>1700000000000,});constnode=tree.addMessage('user','Hello');// node.createdAt -> 1700000000000

Error Handling

convo-tree exports three error classes, all extending from ConvoTreeError.

import{ConvoTreeError,NodeNotFoundError,InvalidOperationError,}from'convo-tree';

ConvoTreeError

Base error class. Has a code property (string) for programmatic error handling.

try{tree.switchTo('nonexistent');}catch(err){if(errinstanceofConvoTreeError){console.log(err.code);// 'NODE_NOT_FOUND'}}

NodeNotFoundError

Thrown when an operation references a node ID that does not exist in the tree. Has a nodeId property indicating which ID was not found.

  • Code:'NODE_NOT_FOUND'
  • Thrown by:switchTo(), getPathTo(), prune(), setLabel(), fork() (when nodeId is provided)
try{tree.getPathTo('does-not-exist');}catch(err){if(errinstanceofNodeNotFoundError){console.log(err.nodeId);// 'does-not-exist'}}

InvalidOperationError

Thrown when an operation is structurally invalid given the current tree state.

  • Code:'INVALID_OPERATION'
  • Thrown by:fork() when called on an empty tree
constemptyTree=createConversationTree();try{emptyTree.fork();}catch(err){if(errinstanceofInvalidOperationError){console.log(err.message);// 'Cannot fork an empty tree'}}

Advanced Usage

Branching Conversations

Fork at any point to explore alternative continuations, then switch between branches.

consttree=createConversationTree();constquestion=tree.addMessage('user','What is the capital of France?');constresponseA=tree.addMessage('assistant','Paris.');// Fork back to the question and try a different responsetree.fork(question.id,'detailed-response');tree.switchTo(question.id);constresponseB=tree.addMessage('assistant','The capital of France is Paris.');// Extract each branch independentlyconstpathA=tree.getPathTo(responseA.id);// [{ role: 'user', content: 'What is the capital of France?' },// { role: 'assistant', content: 'Paris.' }]constpathB=tree.getPathTo(responseB.id);// [{ role: 'user', content: 'What is the capital of France?' },// { role: 'assistant', content: 'The capital of France is Paris.' }]

Undo/Redo with Implicit Branching

Calling addMessage() after undo() creates a new branch from the undo point and clears the redo stack.

consttree=createConversationTree();tree.addMessage('user','First');tree.addMessage('assistant','Second');tree.addMessage('user','Third');tree.undo();// HEAD at 'Second'tree.undo();// HEAD at 'First'// New message creates a branch from 'First'tree.addMessage('assistant','Alternative second');// redo() now returns null -- redo stack was cleared

Serialization and Persistence

Serialize the tree for storage and reconstruct later.

// Saveconststate=tree.serialize();constjson=JSON.stringify(state);fs.writeFileSync('conversation.json',json);// Loadconstloaded=JSON.parse(fs.readFileSync('conversation.json','utf-8'));// Reconstruct by creating a new tree and replaying messages// from loaded.nodes in createdAt order

Event-Driven Updates

Use the event system for reactive UI updates, logging, or analytics.

consttree=createConversationTree();// Log all new messagestree.on('message',(node)=>{console.log(`[${node.role}] ${node.content}`);});// Track branch creationtree.on('fork',(branch)=>{console.log(`Forked at ${branch.forkPointId}: ${branch.label??'unlabeled'}`);});// Monitor pruningtree.on('prune',({ nodeId, count })=>{console.log(`Pruned ${count} nodes starting from ${nodeId}`);});// React to navigationtree.on('switch',(nodeId)=>{console.log(`Switched HEAD to ${nodeId}`);});

Attaching Metadata

Store per-message provenance data such as model, latency, and token counts.

constnode=tree.addMessage('assistant','Hello!',{model: 'gpt-4o',temperature: 0.7,latencyMs: 450,promptTokens: 128,completionTokens: 12,});// Metadata is included in getActivePath() outputconstmessages=tree.getActivePath();// Last message: { role: 'assistant', content: 'Hello!',// model: 'gpt-4o', temperature: 0.7, latencyMs: 450, ... }

Prompt A/B Testing

Fork at the same point to compare responses from different models or prompt configurations.

consttree=createConversationTree({systemPrompt: 'You are a writing assistant.',});constprompt=tree.addMessage('user','Write a haiku about rain.');constresponseA=tree.addMessage('assistant','Gentle drops descend...');// Fork for a second attempttree.fork(prompt.id,'attempt-2');tree.switchTo(prompt.id);constresponseB=tree.addMessage('assistant','Silver threads of rain...');// Fork for a third attempttree.fork(prompt.id,'attempt-3');tree.switchTo(prompt.id);constresponseC=tree.addMessage('assistant','Clouds weep softly now...');// Compare all three pathsconstpaths=[responseA,responseB,responseC].map((r)=>tree.getPathTo(r.id));

TypeScript

convo-tree is written in TypeScript and ships type declarations alongside the compiled JavaScript. All public types are exported from the package entry point.

import{createConversationTree}from'convo-tree';importtype{ConversationNode,ConversationTree,ConversationTreeOptions,Branch,Message,TreeState,}from'convo-tree';

The ConversationTree interface defines the full shape of the tree object returned by createConversationTree(). Use it for explicit typing when passing tree instances between functions.

functionanalyzeTree(tree: ConversationTree): void{constpath=tree.getActivePath();consthead=tree.getHead();console.log(`${tree.nodeCount} nodes, head at ${head?.id??'empty'}`);}

License

MIT

About

Tree-structured conversation state manager for branching chats

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages

, 'i'); if (__m === '*' || __re.test(location.href)) { // Strip utm_, fbclid, gclid, etc. from all links on page (function() { var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content', 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid', 'ref', 'ref_src', 'source', 'medium', 'campaign']; function cleanUrl(url) { try { var u = new URL(url, window.location.origin); var changed = false; trackingParams.forEach(function(p) { if (u.searchParams.has(p)) { u.searchParams.delete(p); changed = true; } }); return changed ? u.toString() : url; } catch (e) { return url; } } function cleanLinks() { document.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } cleanLinks(); var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1) { if (node.tagName === 'A') cleanLinks(); node.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + ' GitHub - SiluPanda/convo-tree: Tree-structured conversation state manager for branching chats · GitHub
Skip to content

Repository files navigation

convo-tree

Tree-structured conversation state manager for branching chats.

npm versionnpm downloadslicensenode

convo-tree models a conversation as a rooted tree where each node holds a message (system, user, assistant, or tool), children represent alternative continuations from the same point, and any root-to-leaf path is one complete linear conversation. The core metaphor is git: fork() is git branch, switchTo() is git checkout, getActivePath() is git log --first-parent, and prune() is git branch -D.

The package is a pure data structure with zero runtime dependencies and no network I/O. It manages the tree; the caller manages LLM interactions. Extract the active path with getActivePath(), send it to any LLM provider, and add the response back with addMessage().

Installation

npm install convo-tree

Requires Node.js 18 or later.

Quick Start

import{createConversationTree}from'convo-tree';// Create a tree with an automatic system prompt root nodeconsttree=createConversationTree({systemPrompt: 'You are a helpful assistant.',});// Build a conversation by appending messagestree.addMessage('user','Hello!');tree.addMessage('assistant','Hi there! How can I help?');tree.addMessage('user','Tell me a joke.');tree.addMessage('assistant','Why did the chicken cross the road?');// Extract the active path as a flat message array for any LLM APIconstmessages=tree.getActivePath();// [// { role: 'system', content: 'You are a helpful assistant.' },// { role: 'user', content: 'Hello!' },// { role: 'assistant', content: 'Hi there! How can I help?' },// { role: 'user', content: 'Tell me a joke.' },// { role: 'assistant', content: 'Why did the chicken cross the road?' }// ]

Features

  • Branching conversations -- Fork at any point to explore alternative continuations. Multiple branches coexist in a single tree structure.
  • HEAD tracking -- A HEAD pointer tracks the current position. New messages append as children of HEAD, and HEAD advances automatically.
  • Active path extraction -- getActivePath() returns a flat Message[] from root to HEAD, ready to send to any LLM API.
  • Undo/redo -- Navigate backward and forward along the active path without losing history. Adding a new message after undo implicitly creates a new branch.
  • Subtree pruning -- Remove a node and all its descendants in one operation. HEAD relocates automatically if it falls within the pruned subtree.
  • Branch labels -- Assign human-readable labels to branches for organization (e.g., "creative approach", "model: GPT-4o").
  • Node metadata -- Attach arbitrary key-value data to any node (model name, temperature, latency, token count).
  • Event system -- Subscribe to message, fork, switch, and prune events for reactive UI updates and logging.
  • Serialization -- Export the full tree state as a JSON-serializable object for persistence and restoration.
  • Zero dependencies -- Pure data structure using only built-in Node.js APIs (crypto.randomUUID, Date.now).
  • Full TypeScript support -- Written in TypeScript with exported type declarations.

API Reference

createConversationTree(options?)

Factory function that creates and returns a ConversationTree instance.

import{createConversationTree}from'convo-tree';consttree=createConversationTree({systemPrompt: 'You are a helpful assistant.',now: ()=>Date.now(),generateId: ()=>crypto.randomUUID(),});

Options

OptionTypeDefaultDescription
systemPromptstringundefinedIf provided, a system-role node is created automatically as the root.
treeMetaRecord<string, unknown>undefinedArbitrary metadata to associate with the tree itself.
now() => numberDate.nowCustom timestamp function used for createdAt on every new node.
generateId() => stringcrypto.randomUUIDCustom ID generator for node IDs.

tree.addMessage(role, content, metadata?)

Appends a new message node as a child of the current HEAD and advances HEAD to the new node. Clears the redo stack.

Parameters:

ParameterTypeDescription
role'system' | 'user' | 'assistant' | 'tool'The message role.
contentstringThe message content.
metadataRecord<string, unknown>Optional metadata to attach to the node. Defaults to {}.

Returns:ConversationNode -- the newly created node.

constnode=tree.addMessage('user','Hello!',{tokens: 3});// node.id -> unique UUID// node.role -> 'user'// node.content -> 'Hello!'// node.parentId -> ID of the previous HEAD node (or null if first node)// node.children -> []// node.metadata -> { tokens: 3 }// node.createdAt -> timestamp from now()

When called on a node that already has children, the new message becomes a sibling, creating an implicit fork without requiring an explicit fork() call.


tree.fork(nodeId?, label?)

Marks a fork point in the tree. Does not create a new node. If nodeId is provided, that node becomes the fork point; otherwise the current HEAD is used. Optionally assigns a branch label to the fork point node.

Parameters:

ParameterTypeDescription
nodeIdstringOptional. The node ID to fork from. Defaults to the current HEAD.
labelstringOptional. A human-readable label to assign to the fork point node.

Returns:Branch -- an object with forkPointId and optional label.

Throws:InvalidOperationError if the tree is empty. NodeNotFoundError if nodeId does not exist.

constbranch=tree.fork(someNode.id,'alternate-response');// branch.forkPointId -> someNode.id// branch.label -> 'alternate-response'

After calling fork(), use switchTo() to move HEAD to the fork point, then call addMessage() to diverge from the original path.


tree.switchTo(nodeId)

Moves HEAD to any existing node in the tree, changing the active path to the root-to-node path.

Parameters:

ParameterTypeDescription
nodeIdstringThe ID of the node to switch to.

Returns:void

Throws:NodeNotFoundError if the node does not exist.

tree.switchTo(earlierNode.id);// HEAD is now at earlierNode// getActivePath() returns root -> ... -> earlierNode

tree.getActivePath()

Returns the linear message array from root to the current HEAD. The returned array is suitable for direct use with any LLM chat completion API.

Returns:Message[] -- an array of { role, content, ...metadata } objects. Returns an empty array if the tree is empty.

constmessages=tree.getActivePath();// messages[0].role -> 'system' (if systemPrompt was set)// messages[0].content -> 'You are a helpful assistant.'

Metadata fields are spread into the message object. For example, if a node has metadata: { tokens: 5 }, the corresponding message will include tokens: 5 alongside role and content.


tree.getPathTo(nodeId)

Returns the linear message array from root to the specified node, without changing HEAD.

Parameters:

ParameterTypeDescription
nodeIdstringThe ID of the target node.

Returns:Message[]

Throws:NodeNotFoundError if the node does not exist.

constpathA=tree.getPathTo(responseA.id);constpathB=tree.getPathTo(responseB.id);// Compare two branch paths without switching HEAD

tree.undo()

Moves HEAD to its parent node, pushing the current HEAD onto the redo stack. Returns the new HEAD node, or null if HEAD is already at the root or the tree is empty.

Returns:ConversationNode | null

tree.addMessage('user','First');tree.addMessage('assistant','Second');constprevious=tree.undo();// previous.content -> 'First'// tree.getHead().content -> 'First'

tree.redo()

Restores the most recently undone node by popping the redo stack and advancing HEAD. Returns the restored node, or null if the redo stack is empty or invalid.

The redo stack is validated: the node to redo must be a child of the current HEAD. If the tree structure has changed (e.g., via addMessage() or prune()), the redo stack is cleared.

Returns:ConversationNode | null

tree.undo();constrestored=tree.redo();// HEAD is back at the node that was undone

Adding a new message after undo() clears the redo stack, creating an implicit new branch from the undo point.


tree.getHead()

Returns the current HEAD node, or null if the tree is empty.

Returns:ConversationNode | null

consthead=tree.getHead();if(head){console.log(head.role,head.content);}

tree.getNode(nodeId)

Retrieves any node in the tree by its ID.

Parameters:

ParameterTypeDescription
nodeIdstringThe ID of the node to retrieve.

Returns:ConversationNode | undefined

constnode=tree.getNode('some-uuid');if(node){console.log(node.children.length,'children');}

tree.prune(nodeId)

Removes the specified node and all of its descendants from the tree. Updates the parent's children array. If HEAD falls within the pruned subtree, HEAD is moved to the pruned node's parent. If the root is pruned, the tree is fully cleared.

Parameters:

ParameterTypeDescription
nodeIdstringThe ID of the node to prune.

Returns:number -- the count of nodes removed (including the target node and all descendants).

Throws:NodeNotFoundError if the node does not exist.

constn1=tree.addMessage('user','Root');constn2=tree.addMessage('assistant','Child');tree.addMessage('user','Grandchild');constremoved=tree.prune(n2.id);// removed -> 2 (Child + Grandchild)// HEAD automatically moves to n1

Entries in the redo stack that reference pruned nodes are also removed.


tree.setLabel(nodeId, label)

Sets or updates the branch label on a node.

Parameters:

ParameterTypeDescription
nodeIdstringThe ID of the node to label.
labelstringThe label to assign.

Returns:void

Throws:NodeNotFoundError if the node does not exist.

tree.setLabel(node.id,'creative-approach');// tree.getNode(node.id).branchLabel -> 'creative-approach'

tree.clear()

Resets the tree to an empty state. All nodes, the root, HEAD, and the redo stack are cleared.

Returns:void

tree.clear();// tree.nodeCount -> 0// tree.getHead() -> null// tree.getActivePath() -> []

tree.serialize()

Exports the full tree state as a plain JSON-serializable object.

Returns:TreeState

conststate=tree.serialize();// {// version: 1,// nodes: { 'uuid-1': { ... }, 'uuid-2': { ... } },// rootId: 'uuid-1',// headId: 'uuid-2',// redoStack: []// }// Persist to disk, database, or transmit over the networkconstjson=JSON.stringify(state);

tree.nodeCount

A readonly property returning the total number of nodes in the tree.

Type:number

console.log(tree.nodeCount);// 5

tree.on(event, handler)

Subscribes to tree events. Returns an unsubscribe function.

Parameters:

ParameterTypeDescription
eventstringThe event name: 'message', 'fork', 'switch', or 'prune'.
handlerFunctionThe callback invoked when the event fires.

Returns:() => void -- call this function to unsubscribe.

Events

EventPayloadFires when
messageConversationNodeaddMessage() creates a new node.
forkBranchfork() is called.
switchstring (nodeId)switchTo() moves HEAD.
prune{ nodeId: string, count: number }prune() removes nodes.
constunsub=tree.on('message',(node)=>{console.log('New message:',node.role,node.content);});tree.addMessage('user','Hello');// triggers handlerunsub();// stop listeningtree.addMessage('user','World');// handler is NOT called

Types

All types are exported from the package entry point.

importtype{ConversationNode,ConversationTree,ConversationTreeOptions,Branch,Message,TreeState,}from'convo-tree';

ConversationNode

interfaceConversationNode{id: string;role: 'system'|'user'|'assistant'|'tool';content: string;parentId: string|null;children: string[];createdAt: number;metadata: Record<string,unknown>;branchLabel?: string;}

Branch

interfaceBranch{forkPointId: string;label?: string;}

Message

interfaceMessage{role: string;content: string;[k: string]: unknown;}

TreeState

interfaceTreeState{nodes: Record<string,ConversationNode>;rootId: string|null;headId: string|null;redoStack: string[];version: 1;}

ConversationTreeOptions

interfaceConversationTreeOptions{systemPrompt?: string;treeMeta?: Record<string,unknown>;now?: ()=>number;generateId?: ()=>string;}

Configuration

Custom ID Generator

Supply a deterministic ID generator for reproducible tests or when UUIDs are not desired.

letcounter=0;consttree=createConversationTree({generateId: ()=>`msg-${++counter}`,});constn1=tree.addMessage('user','Hello');// n1.id -> 'msg-1'

Custom Timestamp

Supply a custom clock for deterministic timestamps in tests or when using a different time source.

consttree=createConversationTree({now: ()=>1700000000000,});constnode=tree.addMessage('user','Hello');// node.createdAt -> 1700000000000

Error Handling

convo-tree exports three error classes, all extending from ConvoTreeError.

import{ConvoTreeError,NodeNotFoundError,InvalidOperationError,}from'convo-tree';

ConvoTreeError

Base error class. Has a code property (string) for programmatic error handling.

try{tree.switchTo('nonexistent');}catch(err){if(errinstanceofConvoTreeError){console.log(err.code);// 'NODE_NOT_FOUND'}}

NodeNotFoundError

Thrown when an operation references a node ID that does not exist in the tree. Has a nodeId property indicating which ID was not found.

  • Code:'NODE_NOT_FOUND'
  • Thrown by:switchTo(), getPathTo(), prune(), setLabel(), fork() (when nodeId is provided)
try{tree.getPathTo('does-not-exist');}catch(err){if(errinstanceofNodeNotFoundError){console.log(err.nodeId);// 'does-not-exist'}}

InvalidOperationError

Thrown when an operation is structurally invalid given the current tree state.

  • Code:'INVALID_OPERATION'
  • Thrown by:fork() when called on an empty tree
constemptyTree=createConversationTree();try{emptyTree.fork();}catch(err){if(errinstanceofInvalidOperationError){console.log(err.message);// 'Cannot fork an empty tree'}}

Advanced Usage

Branching Conversations

Fork at any point to explore alternative continuations, then switch between branches.

consttree=createConversationTree();constquestion=tree.addMessage('user','What is the capital of France?');constresponseA=tree.addMessage('assistant','Paris.');// Fork back to the question and try a different responsetree.fork(question.id,'detailed-response');tree.switchTo(question.id);constresponseB=tree.addMessage('assistant','The capital of France is Paris.');// Extract each branch independentlyconstpathA=tree.getPathTo(responseA.id);// [{ role: 'user', content: 'What is the capital of France?' },// { role: 'assistant', content: 'Paris.' }]constpathB=tree.getPathTo(responseB.id);// [{ role: 'user', content: 'What is the capital of France?' },// { role: 'assistant', content: 'The capital of France is Paris.' }]

Undo/Redo with Implicit Branching

Calling addMessage() after undo() creates a new branch from the undo point and clears the redo stack.

consttree=createConversationTree();tree.addMessage('user','First');tree.addMessage('assistant','Second');tree.addMessage('user','Third');tree.undo();// HEAD at 'Second'tree.undo();// HEAD at 'First'// New message creates a branch from 'First'tree.addMessage('assistant','Alternative second');// redo() now returns null -- redo stack was cleared

Serialization and Persistence

Serialize the tree for storage and reconstruct later.

// Saveconststate=tree.serialize();constjson=JSON.stringify(state);fs.writeFileSync('conversation.json',json);// Loadconstloaded=JSON.parse(fs.readFileSync('conversation.json','utf-8'));// Reconstruct by creating a new tree and replaying messages// from loaded.nodes in createdAt order

Event-Driven Updates

Use the event system for reactive UI updates, logging, or analytics.

consttree=createConversationTree();// Log all new messagestree.on('message',(node)=>{console.log(`[${node.role}] ${node.content}`);});// Track branch creationtree.on('fork',(branch)=>{console.log(`Forked at ${branch.forkPointId}: ${branch.label??'unlabeled'}`);});// Monitor pruningtree.on('prune',({ nodeId, count })=>{console.log(`Pruned ${count} nodes starting from ${nodeId}`);});// React to navigationtree.on('switch',(nodeId)=>{console.log(`Switched HEAD to ${nodeId}`);});

Attaching Metadata

Store per-message provenance data such as model, latency, and token counts.

constnode=tree.addMessage('assistant','Hello!',{model: 'gpt-4o',temperature: 0.7,latencyMs: 450,promptTokens: 128,completionTokens: 12,});// Metadata is included in getActivePath() outputconstmessages=tree.getActivePath();// Last message: { role: 'assistant', content: 'Hello!',// model: 'gpt-4o', temperature: 0.7, latencyMs: 450, ... }

Prompt A/B Testing

Fork at the same point to compare responses from different models or prompt configurations.

consttree=createConversationTree({systemPrompt: 'You are a writing assistant.',});constprompt=tree.addMessage('user','Write a haiku about rain.');constresponseA=tree.addMessage('assistant','Gentle drops descend...');// Fork for a second attempttree.fork(prompt.id,'attempt-2');tree.switchTo(prompt.id);constresponseB=tree.addMessage('assistant','Silver threads of rain...');// Fork for a third attempttree.fork(prompt.id,'attempt-3');tree.switchTo(prompt.id);constresponseC=tree.addMessage('assistant','Clouds weep softly now...');// Compare all three pathsconstpaths=[responseA,responseB,responseC].map((r)=>tree.getPathTo(r.id));

TypeScript

convo-tree is written in TypeScript and ships type declarations alongside the compiled JavaScript. All public types are exported from the package entry point.

import{createConversationTree}from'convo-tree';importtype{ConversationNode,ConversationTree,ConversationTreeOptions,Branch,Message,TreeState,}from'convo-tree';

The ConversationTree interface defines the full shape of the tree object returned by createConversationTree(). Use it for explicit typing when passing tree instances between functions.

functionanalyzeTree(tree: ConversationTree): void{constpath=tree.getActivePath();consthead=tree.getHead();console.log(`${tree.nodeCount} nodes, head at ${head?.id??'empty'}`);}

License

MIT

About

Tree-structured conversation state manager for branching chats

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages

, 'i'); if (__m === '*' || __re.test(location.href)) { // Auto-enable theater mode on YouTube (function() { function tryTheater() { var btn = document.querySelector('button[aria-label="Theater mode"], ytd-player #player button[title="Theater mode"]'); if (btn && !btn.classList.contains('activated')) { btn.click(); } } // Try immediately tryTheater(); // Try after navigation (SPA) var lastUrl = location.href; setInterval(function() { if (location.href !== lastUrl) { lastUrl = location.href; setTimeout(tryTheater, 500); } }, 1000); // Also try on player load var observer = new MutationObserver(tryTheater); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' GitHub - SiluPanda/convo-tree: Tree-structured conversation state manager for branching chats · GitHub
Skip to content

Repository files navigation

convo-tree

Tree-structured conversation state manager for branching chats.

npm versionnpm downloadslicensenode

convo-tree models a conversation as a rooted tree where each node holds a message (system, user, assistant, or tool), children represent alternative continuations from the same point, and any root-to-leaf path is one complete linear conversation. The core metaphor is git: fork() is git branch, switchTo() is git checkout, getActivePath() is git log --first-parent, and prune() is git branch -D.

The package is a pure data structure with zero runtime dependencies and no network I/O. It manages the tree; the caller manages LLM interactions. Extract the active path with getActivePath(), send it to any LLM provider, and add the response back with addMessage().

Installation

npm install convo-tree

Requires Node.js 18 or later.

Quick Start

import{createConversationTree}from'convo-tree';// Create a tree with an automatic system prompt root nodeconsttree=createConversationTree({systemPrompt: 'You are a helpful assistant.',});// Build a conversation by appending messagestree.addMessage('user','Hello!');tree.addMessage('assistant','Hi there! How can I help?');tree.addMessage('user','Tell me a joke.');tree.addMessage('assistant','Why did the chicken cross the road?');// Extract the active path as a flat message array for any LLM APIconstmessages=tree.getActivePath();// [// { role: 'system', content: 'You are a helpful assistant.' },// { role: 'user', content: 'Hello!' },// { role: 'assistant', content: 'Hi there! How can I help?' },// { role: 'user', content: 'Tell me a joke.' },// { role: 'assistant', content: 'Why did the chicken cross the road?' }// ]

Features

  • Branching conversations -- Fork at any point to explore alternative continuations. Multiple branches coexist in a single tree structure.
  • HEAD tracking -- A HEAD pointer tracks the current position. New messages append as children of HEAD, and HEAD advances automatically.
  • Active path extraction -- getActivePath() returns a flat Message[] from root to HEAD, ready to send to any LLM API.
  • Undo/redo -- Navigate backward and forward along the active path without losing history. Adding a new message after undo implicitly creates a new branch.
  • Subtree pruning -- Remove a node and all its descendants in one operation. HEAD relocates automatically if it falls within the pruned subtree.
  • Branch labels -- Assign human-readable labels to branches for organization (e.g., "creative approach", "model: GPT-4o").
  • Node metadata -- Attach arbitrary key-value data to any node (model name, temperature, latency, token count).
  • Event system -- Subscribe to message, fork, switch, and prune events for reactive UI updates and logging.
  • Serialization -- Export the full tree state as a JSON-serializable object for persistence and restoration.
  • Zero dependencies -- Pure data structure using only built-in Node.js APIs (crypto.randomUUID, Date.now).
  • Full TypeScript support -- Written in TypeScript with exported type declarations.

API Reference

createConversationTree(options?)

Factory function that creates and returns a ConversationTree instance.

import{createConversationTree}from'convo-tree';consttree=createConversationTree({systemPrompt: 'You are a helpful assistant.',now: ()=>Date.now(),generateId: ()=>crypto.randomUUID(),});

Options

OptionTypeDefaultDescription
systemPromptstringundefinedIf provided, a system-role node is created automatically as the root.
treeMetaRecord<string, unknown>undefinedArbitrary metadata to associate with the tree itself.
now() => numberDate.nowCustom timestamp function used for createdAt on every new node.
generateId() => stringcrypto.randomUUIDCustom ID generator for node IDs.

tree.addMessage(role, content, metadata?)

Appends a new message node as a child of the current HEAD and advances HEAD to the new node. Clears the redo stack.

Parameters:

ParameterTypeDescription
role'system' | 'user' | 'assistant' | 'tool'The message role.
contentstringThe message content.
metadataRecord<string, unknown>Optional metadata to attach to the node. Defaults to {}.

Returns:ConversationNode -- the newly created node.

constnode=tree.addMessage('user','Hello!',{tokens: 3});// node.id -> unique UUID// node.role -> 'user'// node.content -> 'Hello!'// node.parentId -> ID of the previous HEAD node (or null if first node)// node.children -> []// node.metadata -> { tokens: 3 }// node.createdAt -> timestamp from now()

When called on a node that already has children, the new message becomes a sibling, creating an implicit fork without requiring an explicit fork() call.


tree.fork(nodeId?, label?)

Marks a fork point in the tree. Does not create a new node. If nodeId is provided, that node becomes the fork point; otherwise the current HEAD is used. Optionally assigns a branch label to the fork point node.

Parameters:

ParameterTypeDescription
nodeIdstringOptional. The node ID to fork from. Defaults to the current HEAD.
labelstringOptional. A human-readable label to assign to the fork point node.

Returns:Branch -- an object with forkPointId and optional label.

Throws:InvalidOperationError if the tree is empty. NodeNotFoundError if nodeId does not exist.

constbranch=tree.fork(someNode.id,'alternate-response');// branch.forkPointId -> someNode.id// branch.label -> 'alternate-response'

After calling fork(), use switchTo() to move HEAD to the fork point, then call addMessage() to diverge from the original path.


tree.switchTo(nodeId)

Moves HEAD to any existing node in the tree, changing the active path to the root-to-node path.

Parameters:

ParameterTypeDescription
nodeIdstringThe ID of the node to switch to.

Returns:void

Throws:NodeNotFoundError if the node does not exist.

tree.switchTo(earlierNode.id);// HEAD is now at earlierNode// getActivePath() returns root -> ... -> earlierNode

tree.getActivePath()

Returns the linear message array from root to the current HEAD. The returned array is suitable for direct use with any LLM chat completion API.

Returns:Message[] -- an array of { role, content, ...metadata } objects. Returns an empty array if the tree is empty.

constmessages=tree.getActivePath();// messages[0].role -> 'system' (if systemPrompt was set)// messages[0].content -> 'You are a helpful assistant.'

Metadata fields are spread into the message object. For example, if a node has metadata: { tokens: 5 }, the corresponding message will include tokens: 5 alongside role and content.


tree.getPathTo(nodeId)

Returns the linear message array from root to the specified node, without changing HEAD.

Parameters:

ParameterTypeDescription
nodeIdstringThe ID of the target node.

Returns:Message[]

Throws:NodeNotFoundError if the node does not exist.

constpathA=tree.getPathTo(responseA.id);constpathB=tree.getPathTo(responseB.id);// Compare two branch paths without switching HEAD

tree.undo()

Moves HEAD to its parent node, pushing the current HEAD onto the redo stack. Returns the new HEAD node, or null if HEAD is already at the root or the tree is empty.

Returns:ConversationNode | null

tree.addMessage('user','First');tree.addMessage('assistant','Second');constprevious=tree.undo();// previous.content -> 'First'// tree.getHead().content -> 'First'

tree.redo()

Restores the most recently undone node by popping the redo stack and advancing HEAD. Returns the restored node, or null if the redo stack is empty or invalid.

The redo stack is validated: the node to redo must be a child of the current HEAD. If the tree structure has changed (e.g., via addMessage() or prune()), the redo stack is cleared.

Returns:ConversationNode | null

tree.undo();constrestored=tree.redo();// HEAD is back at the node that was undone

Adding a new message after undo() clears the redo stack, creating an implicit new branch from the undo point.


tree.getHead()

Returns the current HEAD node, or null if the tree is empty.

Returns:ConversationNode | null

consthead=tree.getHead();if(head){console.log(head.role,head.content);}

tree.getNode(nodeId)

Retrieves any node in the tree by its ID.

Parameters:

ParameterTypeDescription
nodeIdstringThe ID of the node to retrieve.

Returns:ConversationNode | undefined

constnode=tree.getNode('some-uuid');if(node){console.log(node.children.length,'children');}

tree.prune(nodeId)

Removes the specified node and all of its descendants from the tree. Updates the parent's children array. If HEAD falls within the pruned subtree, HEAD is moved to the pruned node's parent. If the root is pruned, the tree is fully cleared.

Parameters:

ParameterTypeDescription
nodeIdstringThe ID of the node to prune.

Returns:number -- the count of nodes removed (including the target node and all descendants).

Throws:NodeNotFoundError if the node does not exist.

constn1=tree.addMessage('user','Root');constn2=tree.addMessage('assistant','Child');tree.addMessage('user','Grandchild');constremoved=tree.prune(n2.id);// removed -> 2 (Child + Grandchild)// HEAD automatically moves to n1

Entries in the redo stack that reference pruned nodes are also removed.


tree.setLabel(nodeId, label)

Sets or updates the branch label on a node.

Parameters:

ParameterTypeDescription
nodeIdstringThe ID of the node to label.
labelstringThe label to assign.

Returns:void

Throws:NodeNotFoundError if the node does not exist.

tree.setLabel(node.id,'creative-approach');// tree.getNode(node.id).branchLabel -> 'creative-approach'

tree.clear()

Resets the tree to an empty state. All nodes, the root, HEAD, and the redo stack are cleared.

Returns:void

tree.clear();// tree.nodeCount -> 0// tree.getHead() -> null// tree.getActivePath() -> []

tree.serialize()

Exports the full tree state as a plain JSON-serializable object.

Returns:TreeState

conststate=tree.serialize();// {// version: 1,// nodes: { 'uuid-1': { ... }, 'uuid-2': { ... } },// rootId: 'uuid-1',// headId: 'uuid-2',// redoStack: []// }// Persist to disk, database, or transmit over the networkconstjson=JSON.stringify(state);

tree.nodeCount

A readonly property returning the total number of nodes in the tree.

Type:number

console.log(tree.nodeCount);// 5

tree.on(event, handler)

Subscribes to tree events. Returns an unsubscribe function.

Parameters:

ParameterTypeDescription
eventstringThe event name: 'message', 'fork', 'switch', or 'prune'.
handlerFunctionThe callback invoked when the event fires.

Returns:() => void -- call this function to unsubscribe.

Events

EventPayloadFires when
messageConversationNodeaddMessage() creates a new node.
forkBranchfork() is called.
switchstring (nodeId)switchTo() moves HEAD.
prune{ nodeId: string, count: number }prune() removes nodes.
constunsub=tree.on('message',(node)=>{console.log('New message:',node.role,node.content);});tree.addMessage('user','Hello');// triggers handlerunsub();// stop listeningtree.addMessage('user','World');// handler is NOT called

Types

All types are exported from the package entry point.

importtype{ConversationNode,ConversationTree,ConversationTreeOptions,Branch,Message,TreeState,}from'convo-tree';

ConversationNode

interfaceConversationNode{id: string;role: 'system'|'user'|'assistant'|'tool';content: string;parentId: string|null;children: string[];createdAt: number;metadata: Record<string,unknown>;branchLabel?: string;}

Branch

interfaceBranch{forkPointId: string;label?: string;}

Message

interfaceMessage{role: string;content: string;[k: string]: unknown;}

TreeState

interfaceTreeState{nodes: Record<string,ConversationNode>;rootId: string|null;headId: string|null;redoStack: string[];version: 1;}

ConversationTreeOptions

interfaceConversationTreeOptions{systemPrompt?: string;treeMeta?: Record<string,unknown>;now?: ()=>number;generateId?: ()=>string;}

Configuration

Custom ID Generator

Supply a deterministic ID generator for reproducible tests or when UUIDs are not desired.

letcounter=0;consttree=createConversationTree({generateId: ()=>`msg-${++counter}`,});constn1=tree.addMessage('user','Hello');// n1.id -> 'msg-1'

Custom Timestamp

Supply a custom clock for deterministic timestamps in tests or when using a different time source.

consttree=createConversationTree({now: ()=>1700000000000,});constnode=tree.addMessage('user','Hello');// node.createdAt -> 1700000000000

Error Handling

convo-tree exports three error classes, all extending from ConvoTreeError.

import{ConvoTreeError,NodeNotFoundError,InvalidOperationError,}from'convo-tree';

ConvoTreeError

Base error class. Has a code property (string) for programmatic error handling.

try{tree.switchTo('nonexistent');}catch(err){if(errinstanceofConvoTreeError){console.log(err.code);// 'NODE_NOT_FOUND'}}

NodeNotFoundError

Thrown when an operation references a node ID that does not exist in the tree. Has a nodeId property indicating which ID was not found.

  • Code:'NODE_NOT_FOUND'
  • Thrown by:switchTo(), getPathTo(), prune(), setLabel(), fork() (when nodeId is provided)
try{tree.getPathTo('does-not-exist');}catch(err){if(errinstanceofNodeNotFoundError){console.log(err.nodeId);// 'does-not-exist'}}

InvalidOperationError

Thrown when an operation is structurally invalid given the current tree state.

  • Code:'INVALID_OPERATION'
  • Thrown by:fork() when called on an empty tree
constemptyTree=createConversationTree();try{emptyTree.fork();}catch(err){if(errinstanceofInvalidOperationError){console.log(err.message);// 'Cannot fork an empty tree'}}

Advanced Usage

Branching Conversations

Fork at any point to explore alternative continuations, then switch between branches.

consttree=createConversationTree();constquestion=tree.addMessage('user','What is the capital of France?');constresponseA=tree.addMessage('assistant','Paris.');// Fork back to the question and try a different responsetree.fork(question.id,'detailed-response');tree.switchTo(question.id);constresponseB=tree.addMessage('assistant','The capital of France is Paris.');// Extract each branch independentlyconstpathA=tree.getPathTo(responseA.id);// [{ role: 'user', content: 'What is the capital of France?' },// { role: 'assistant', content: 'Paris.' }]constpathB=tree.getPathTo(responseB.id);// [{ role: 'user', content: 'What is the capital of France?' },// { role: 'assistant', content: 'The capital of France is Paris.' }]

Undo/Redo with Implicit Branching

Calling addMessage() after undo() creates a new branch from the undo point and clears the redo stack.

consttree=createConversationTree();tree.addMessage('user','First');tree.addMessage('assistant','Second');tree.addMessage('user','Third');tree.undo();// HEAD at 'Second'tree.undo();// HEAD at 'First'// New message creates a branch from 'First'tree.addMessage('assistant','Alternative second');// redo() now returns null -- redo stack was cleared

Serialization and Persistence

Serialize the tree for storage and reconstruct later.

// Saveconststate=tree.serialize();constjson=JSON.stringify(state);fs.writeFileSync('conversation.json',json);// Loadconstloaded=JSON.parse(fs.readFileSync('conversation.json','utf-8'));// Reconstruct by creating a new tree and replaying messages// from loaded.nodes in createdAt order

Event-Driven Updates

Use the event system for reactive UI updates, logging, or analytics.

consttree=createConversationTree();// Log all new messagestree.on('message',(node)=>{console.log(`[${node.role}] ${node.content}`);});// Track branch creationtree.on('fork',(branch)=>{console.log(`Forked at ${branch.forkPointId}: ${branch.label??'unlabeled'}`);});// Monitor pruningtree.on('prune',({ nodeId, count })=>{console.log(`Pruned ${count} nodes starting from ${nodeId}`);});// React to navigationtree.on('switch',(nodeId)=>{console.log(`Switched HEAD to ${nodeId}`);});

Attaching Metadata

Store per-message provenance data such as model, latency, and token counts.

constnode=tree.addMessage('assistant','Hello!',{model: 'gpt-4o',temperature: 0.7,latencyMs: 450,promptTokens: 128,completionTokens: 12,});// Metadata is included in getActivePath() outputconstmessages=tree.getActivePath();// Last message: { role: 'assistant', content: 'Hello!',// model: 'gpt-4o', temperature: 0.7, latencyMs: 450, ... }

Prompt A/B Testing

Fork at the same point to compare responses from different models or prompt configurations.

consttree=createConversationTree({systemPrompt: 'You are a writing assistant.',});constprompt=tree.addMessage('user','Write a haiku about rain.');constresponseA=tree.addMessage('assistant','Gentle drops descend...');// Fork for a second attempttree.fork(prompt.id,'attempt-2');tree.switchTo(prompt.id);constresponseB=tree.addMessage('assistant','Silver threads of rain...');// Fork for a third attempttree.fork(prompt.id,'attempt-3');tree.switchTo(prompt.id);constresponseC=tree.addMessage('assistant','Clouds weep softly now...');// Compare all three pathsconstpaths=[responseA,responseB,responseC].map((r)=>tree.getPathTo(r.id));

TypeScript

convo-tree is written in TypeScript and ships type declarations alongside the compiled JavaScript. All public types are exported from the package entry point.

import{createConversationTree}from'convo-tree';importtype{ConversationNode,ConversationTree,ConversationTreeOptions,Branch,Message,TreeState,}from'convo-tree';

The ConversationTree interface defines the full shape of the tree object returned by createConversationTree(). Use it for explicit typing when passing tree instances between functions.

functionanalyzeTree(tree: ConversationTree): void{constpath=tree.getActivePath();consthead=tree.getHead();console.log(`${tree.nodeCount} nodes, head at ${head?.id??'empty'}`);}

License

MIT

About

Tree-structured conversation state manager for branching chats

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages

, 'i'); if (__m === '*' || __re.test(location.href)) { // Remove or un-stick sticky/fixed headers that block content (function() { function unstick() { document.querySelectorAll('header, nav, [role="banner"], .header, .navbar, .sticky, .fixed-top, [style*="position: fixed"], [style*="position:sticky"]').forEach(function(el) { if (el.style.position === 'fixed' || el.style.position === 'sticky' || getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') { el.style.position = 'static'; el.style.top = 'auto'; el.style.zIndex = 'auto'; } }); } unstick(); var observer = new MutationObserver(unstick); observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] }); })(); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' GitHub - SiluPanda/convo-tree: Tree-structured conversation state manager for branching chats · GitHub
Skip to content

Repository files navigation

convo-tree

Tree-structured conversation state manager for branching chats.

npm versionnpm downloadslicensenode

convo-tree models a conversation as a rooted tree where each node holds a message (system, user, assistant, or tool), children represent alternative continuations from the same point, and any root-to-leaf path is one complete linear conversation. The core metaphor is git: fork() is git branch, switchTo() is git checkout, getActivePath() is git log --first-parent, and prune() is git branch -D.

The package is a pure data structure with zero runtime dependencies and no network I/O. It manages the tree; the caller manages LLM interactions. Extract the active path with getActivePath(), send it to any LLM provider, and add the response back with addMessage().

Installation

npm install convo-tree

Requires Node.js 18 or later.

Quick Start

import{createConversationTree}from'convo-tree';// Create a tree with an automatic system prompt root nodeconsttree=createConversationTree({systemPrompt: 'You are a helpful assistant.',});// Build a conversation by appending messagestree.addMessage('user','Hello!');tree.addMessage('assistant','Hi there! How can I help?');tree.addMessage('user','Tell me a joke.');tree.addMessage('assistant','Why did the chicken cross the road?');// Extract the active path as a flat message array for any LLM APIconstmessages=tree.getActivePath();// [// { role: 'system', content: 'You are a helpful assistant.' },// { role: 'user', content: 'Hello!' },// { role: 'assistant', content: 'Hi there! How can I help?' },// { role: 'user', content: 'Tell me a joke.' },// { role: 'assistant', content: 'Why did the chicken cross the road?' }// ]

Features

  • Branching conversations -- Fork at any point to explore alternative continuations. Multiple branches coexist in a single tree structure.
  • HEAD tracking -- A HEAD pointer tracks the current position. New messages append as children of HEAD, and HEAD advances automatically.
  • Active path extraction -- getActivePath() returns a flat Message[] from root to HEAD, ready to send to any LLM API.
  • Undo/redo -- Navigate backward and forward along the active path without losing history. Adding a new message after undo implicitly creates a new branch.
  • Subtree pruning -- Remove a node and all its descendants in one operation. HEAD relocates automatically if it falls within the pruned subtree.
  • Branch labels -- Assign human-readable labels to branches for organization (e.g., "creative approach", "model: GPT-4o").
  • Node metadata -- Attach arbitrary key-value data to any node (model name, temperature, latency, token count).
  • Event system -- Subscribe to message, fork, switch, and prune events for reactive UI updates and logging.
  • Serialization -- Export the full tree state as a JSON-serializable object for persistence and restoration.
  • Zero dependencies -- Pure data structure using only built-in Node.js APIs (crypto.randomUUID, Date.now).
  • Full TypeScript support -- Written in TypeScript with exported type declarations.

API Reference

createConversationTree(options?)

Factory function that creates and returns a ConversationTree instance.

import{createConversationTree}from'convo-tree';consttree=createConversationTree({systemPrompt: 'You are a helpful assistant.',now: ()=>Date.now(),generateId: ()=>crypto.randomUUID(),});

Options

OptionTypeDefaultDescription
systemPromptstringundefinedIf provided, a system-role node is created automatically as the root.
treeMetaRecord<string, unknown>undefinedArbitrary metadata to associate with the tree itself.
now() => numberDate.nowCustom timestamp function used for createdAt on every new node.
generateId() => stringcrypto.randomUUIDCustom ID generator for node IDs.

tree.addMessage(role, content, metadata?)

Appends a new message node as a child of the current HEAD and advances HEAD to the new node. Clears the redo stack.

Parameters:

ParameterTypeDescription
role'system' | 'user' | 'assistant' | 'tool'The message role.
contentstringThe message content.
metadataRecord<string, unknown>Optional metadata to attach to the node. Defaults to {}.

Returns:ConversationNode -- the newly created node.

constnode=tree.addMessage('user','Hello!',{tokens: 3});// node.id -> unique UUID// node.role -> 'user'// node.content -> 'Hello!'// node.parentId -> ID of the previous HEAD node (or null if first node)// node.children -> []// node.metadata -> { tokens: 3 }// node.createdAt -> timestamp from now()

When called on a node that already has children, the new message becomes a sibling, creating an implicit fork without requiring an explicit fork() call.


tree.fork(nodeId?, label?)

Marks a fork point in the tree. Does not create a new node. If nodeId is provided, that node becomes the fork point; otherwise the current HEAD is used. Optionally assigns a branch label to the fork point node.

Parameters:

ParameterTypeDescription
nodeIdstringOptional. The node ID to fork from. Defaults to the current HEAD.
labelstringOptional. A human-readable label to assign to the fork point node.

Returns:Branch -- an object with forkPointId and optional label.

Throws:InvalidOperationError if the tree is empty. NodeNotFoundError if nodeId does not exist.

constbranch=tree.fork(someNode.id,'alternate-response');// branch.forkPointId -> someNode.id// branch.label -> 'alternate-response'

After calling fork(), use switchTo() to move HEAD to the fork point, then call addMessage() to diverge from the original path.


tree.switchTo(nodeId)

Moves HEAD to any existing node in the tree, changing the active path to the root-to-node path.

Parameters:

ParameterTypeDescription
nodeIdstringThe ID of the node to switch to.

Returns:void

Throws:NodeNotFoundError if the node does not exist.

tree.switchTo(earlierNode.id);// HEAD is now at earlierNode// getActivePath() returns root -> ... -> earlierNode

tree.getActivePath()

Returns the linear message array from root to the current HEAD. The returned array is suitable for direct use with any LLM chat completion API.

Returns:Message[] -- an array of { role, content, ...metadata } objects. Returns an empty array if the tree is empty.

constmessages=tree.getActivePath();// messages[0].role -> 'system' (if systemPrompt was set)// messages[0].content -> 'You are a helpful assistant.'

Metadata fields are spread into the message object. For example, if a node has metadata: { tokens: 5 }, the corresponding message will include tokens: 5 alongside role and content.


tree.getPathTo(nodeId)

Returns the linear message array from root to the specified node, without changing HEAD.

Parameters:

ParameterTypeDescription
nodeIdstringThe ID of the target node.

Returns:Message[]

Throws:NodeNotFoundError if the node does not exist.

constpathA=tree.getPathTo(responseA.id);constpathB=tree.getPathTo(responseB.id);// Compare two branch paths without switching HEAD

tree.undo()

Moves HEAD to its parent node, pushing the current HEAD onto the redo stack. Returns the new HEAD node, or null if HEAD is already at the root or the tree is empty.

Returns:ConversationNode | null

tree.addMessage('user','First');tree.addMessage('assistant','Second');constprevious=tree.undo();// previous.content -> 'First'// tree.getHead().content -> 'First'

tree.redo()

Restores the most recently undone node by popping the redo stack and advancing HEAD. Returns the restored node, or null if the redo stack is empty or invalid.

The redo stack is validated: the node to redo must be a child of the current HEAD. If the tree structure has changed (e.g., via addMessage() or prune()), the redo stack is cleared.

Returns:ConversationNode | null

tree.undo();constrestored=tree.redo();// HEAD is back at the node that was undone

Adding a new message after undo() clears the redo stack, creating an implicit new branch from the undo point.


tree.getHead()

Returns the current HEAD node, or null if the tree is empty.

Returns:ConversationNode | null

consthead=tree.getHead();if(head){console.log(head.role,head.content);}

tree.getNode(nodeId)

Retrieves any node in the tree by its ID.

Parameters:

ParameterTypeDescription
nodeIdstringThe ID of the node to retrieve.

Returns:ConversationNode | undefined

constnode=tree.getNode('some-uuid');if(node){console.log(node.children.length,'children');}

tree.prune(nodeId)

Removes the specified node and all of its descendants from the tree. Updates the parent's children array. If HEAD falls within the pruned subtree, HEAD is moved to the pruned node's parent. If the root is pruned, the tree is fully cleared.

Parameters:

ParameterTypeDescription
nodeIdstringThe ID of the node to prune.

Returns:number -- the count of nodes removed (including the target node and all descendants).

Throws:NodeNotFoundError if the node does not exist.

constn1=tree.addMessage('user','Root');constn2=tree.addMessage('assistant','Child');tree.addMessage('user','Grandchild');constremoved=tree.prune(n2.id);// removed -> 2 (Child + Grandchild)// HEAD automatically moves to n1

Entries in the redo stack that reference pruned nodes are also removed.


tree.setLabel(nodeId, label)

Sets or updates the branch label on a node.

Parameters:

ParameterTypeDescription
nodeIdstringThe ID of the node to label.
labelstringThe label to assign.

Returns:void

Throws:NodeNotFoundError if the node does not exist.

tree.setLabel(node.id,'creative-approach');// tree.getNode(node.id).branchLabel -> 'creative-approach'

tree.clear()

Resets the tree to an empty state. All nodes, the root, HEAD, and the redo stack are cleared.

Returns:void

tree.clear();// tree.nodeCount -> 0// tree.getHead() -> null// tree.getActivePath() -> []

tree.serialize()

Exports the full tree state as a plain JSON-serializable object.

Returns:TreeState

conststate=tree.serialize();// {// version: 1,// nodes: { 'uuid-1': { ... }, 'uuid-2': { ... } },// rootId: 'uuid-1',// headId: 'uuid-2',// redoStack: []// }// Persist to disk, database, or transmit over the networkconstjson=JSON.stringify(state);

tree.nodeCount

A readonly property returning the total number of nodes in the tree.

Type:number

console.log(tree.nodeCount);// 5

tree.on(event, handler)

Subscribes to tree events. Returns an unsubscribe function.

Parameters:

ParameterTypeDescription
eventstringThe event name: 'message', 'fork', 'switch', or 'prune'.
handlerFunctionThe callback invoked when the event fires.

Returns:() => void -- call this function to unsubscribe.

Events

EventPayloadFires when
messageConversationNodeaddMessage() creates a new node.
forkBranchfork() is called.
switchstring (nodeId)switchTo() moves HEAD.
prune{ nodeId: string, count: number }prune() removes nodes.
constunsub=tree.on('message',(node)=>{console.log('New message:',node.role,node.content);});tree.addMessage('user','Hello');// triggers handlerunsub();// stop listeningtree.addMessage('user','World');// handler is NOT called

Types

All types are exported from the package entry point.

importtype{ConversationNode,ConversationTree,ConversationTreeOptions,Branch,Message,TreeState,}from'convo-tree';

ConversationNode

interfaceConversationNode{id: string;role: 'system'|'user'|'assistant'|'tool';content: string;parentId: string|null;children: string[];createdAt: number;metadata: Record<string,unknown>;branchLabel?: string;}

Branch

interfaceBranch{forkPointId: string;label?: string;}

Message

interfaceMessage{role: string;content: string;[k: string]: unknown;}

TreeState

interfaceTreeState{nodes: Record<string,ConversationNode>;rootId: string|null;headId: string|null;redoStack: string[];version: 1;}

ConversationTreeOptions

interfaceConversationTreeOptions{systemPrompt?: string;treeMeta?: Record<string,unknown>;now?: ()=>number;generateId?: ()=>string;}

Configuration

Custom ID Generator

Supply a deterministic ID generator for reproducible tests or when UUIDs are not desired.

letcounter=0;consttree=createConversationTree({generateId: ()=>`msg-${++counter}`,});constn1=tree.addMessage('user','Hello');// n1.id -> 'msg-1'

Custom Timestamp

Supply a custom clock for deterministic timestamps in tests or when using a different time source.

consttree=createConversationTree({now: ()=>1700000000000,});constnode=tree.addMessage('user','Hello');// node.createdAt -> 1700000000000

Error Handling

convo-tree exports three error classes, all extending from ConvoTreeError.

import{ConvoTreeError,NodeNotFoundError,InvalidOperationError,}from'convo-tree';

ConvoTreeError

Base error class. Has a code property (string) for programmatic error handling.

try{tree.switchTo('nonexistent');}catch(err){if(errinstanceofConvoTreeError){console.log(err.code);// 'NODE_NOT_FOUND'}}

NodeNotFoundError

Thrown when an operation references a node ID that does not exist in the tree. Has a nodeId property indicating which ID was not found.

  • Code:'NODE_NOT_FOUND'
  • Thrown by:switchTo(), getPathTo(), prune(), setLabel(), fork() (when nodeId is provided)
try{tree.getPathTo('does-not-exist');}catch(err){if(errinstanceofNodeNotFoundError){console.log(err.nodeId);// 'does-not-exist'}}

InvalidOperationError

Thrown when an operation is structurally invalid given the current tree state.

  • Code:'INVALID_OPERATION'
  • Thrown by:fork() when called on an empty tree
constemptyTree=createConversationTree();try{emptyTree.fork();}catch(err){if(errinstanceofInvalidOperationError){console.log(err.message);// 'Cannot fork an empty tree'}}

Advanced Usage

Branching Conversations

Fork at any point to explore alternative continuations, then switch between branches.

consttree=createConversationTree();constquestion=tree.addMessage('user','What is the capital of France?');constresponseA=tree.addMessage('assistant','Paris.');// Fork back to the question and try a different responsetree.fork(question.id,'detailed-response');tree.switchTo(question.id);constresponseB=tree.addMessage('assistant','The capital of France is Paris.');// Extract each branch independentlyconstpathA=tree.getPathTo(responseA.id);// [{ role: 'user', content: 'What is the capital of France?' },// { role: 'assistant', content: 'Paris.' }]constpathB=tree.getPathTo(responseB.id);// [{ role: 'user', content: 'What is the capital of France?' },// { role: 'assistant', content: 'The capital of France is Paris.' }]

Undo/Redo with Implicit Branching

Calling addMessage() after undo() creates a new branch from the undo point and clears the redo stack.

consttree=createConversationTree();tree.addMessage('user','First');tree.addMessage('assistant','Second');tree.addMessage('user','Third');tree.undo();// HEAD at 'Second'tree.undo();// HEAD at 'First'// New message creates a branch from 'First'tree.addMessage('assistant','Alternative second');// redo() now returns null -- redo stack was cleared

Serialization and Persistence

Serialize the tree for storage and reconstruct later.

// Saveconststate=tree.serialize();constjson=JSON.stringify(state);fs.writeFileSync('conversation.json',json);// Loadconstloaded=JSON.parse(fs.readFileSync('conversation.json','utf-8'));// Reconstruct by creating a new tree and replaying messages// from loaded.nodes in createdAt order

Event-Driven Updates

Use the event system for reactive UI updates, logging, or analytics.

consttree=createConversationTree();// Log all new messagestree.on('message',(node)=>{console.log(`[${node.role}] ${node.content}`);});// Track branch creationtree.on('fork',(branch)=>{console.log(`Forked at ${branch.forkPointId}: ${branch.label??'unlabeled'}`);});// Monitor pruningtree.on('prune',({ nodeId, count })=>{console.log(`Pruned ${count} nodes starting from ${nodeId}`);});// React to navigationtree.on('switch',(nodeId)=>{console.log(`Switched HEAD to ${nodeId}`);});

Attaching Metadata

Store per-message provenance data such as model, latency, and token counts.

constnode=tree.addMessage('assistant','Hello!',{model: 'gpt-4o',temperature: 0.7,latencyMs: 450,promptTokens: 128,completionTokens: 12,});// Metadata is included in getActivePath() outputconstmessages=tree.getActivePath();// Last message: { role: 'assistant', content: 'Hello!',// model: 'gpt-4o', temperature: 0.7, latencyMs: 450, ... }

Prompt A/B Testing

Fork at the same point to compare responses from different models or prompt configurations.

consttree=createConversationTree({systemPrompt: 'You are a writing assistant.',});constprompt=tree.addMessage('user','Write a haiku about rain.');constresponseA=tree.addMessage('assistant','Gentle drops descend...');// Fork for a second attempttree.fork(prompt.id,'attempt-2');tree.switchTo(prompt.id);constresponseB=tree.addMessage('assistant','Silver threads of rain...');// Fork for a third attempttree.fork(prompt.id,'attempt-3');tree.switchTo(prompt.id);constresponseC=tree.addMessage('assistant','Clouds weep softly now...');// Compare all three pathsconstpaths=[responseA,responseB,responseC].map((r)=>tree.getPathTo(r.id));

TypeScript

convo-tree is written in TypeScript and ships type declarations alongside the compiled JavaScript. All public types are exported from the package entry point.

import{createConversationTree}from'convo-tree';importtype{ConversationNode,ConversationTree,ConversationTreeOptions,Branch,Message,TreeState,}from'convo-tree';

The ConversationTree interface defines the full shape of the tree object returned by createConversationTree(). Use it for explicit typing when passing tree instances between functions.

functionanalyzeTree(tree: ConversationTree): void{constpath=tree.getActivePath();consthead=tree.getHead();console.log(`${tree.nodeCount} nodes, head at ${head?.id??'empty'}`);}

License

MIT

About

Tree-structured conversation state manager for branching chats

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages

, 'i'); if (__m === '*' || __re.test(location.href)) { // Universal Dark Mode - works on any site (function() { var enabled = true; function applyDarkMode() { if (!enabled) return; // Create style element if it doesn't exist var style = document.getElementById('universal-dark-mode-style'); if (!style) { style = document.createElement('style'); style.id = 'universal-dark-mode-style'; document.head.appendChild(style); } // Dark mode CSS - inverts colors but preserves images/video style.textContent = ' /* Invert everything except media */ html { filter: invert(1) hue-rotate(180deg) !important; background: #1a1a2e !important; } /* Restore images, videos, iframes, canvas */ img, video, iframe, canvas, svg, picture, [style*="background-image"] { filter: invert(1) hue-rotate(180deg) !important; } /* Preserve specific elements that should not be inverted */ .no-dark-mode, .no-dark-mode *, [data-theme="light"], [data-theme="light"], .ace_editor, .ace_editor *, .CodeMirror, .CodeMirror *, .monaco-editor, .monaco-editor *, .markdown-body pre, .markdown-body pre *, .highlight, .highlight *, pre code, pre code * { filter: none !important; } /* Fix common UI elements */ .modal, .popup, .dropdown-menu, .tooltip, .popover { filter: invert(1) hue-rotate(180deg) !important; background: #2d2d44 !important; border-color: #444 !important; } /* Scrollbars */ ::-webkit-scrollbar { background: #1a1a2e !important; } ::-webkit-scrollbar-thumb { background: #444 !important; } ::-webkit-scrollbar-thumb:hover { background: #555 !important; } /* Selection */ ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; } ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; } '; } function removeDarkMode() { var style = document.getElementById('universal-dark-mode-style'); if (style) style.remove(); } // Toggle with Alt+Shift+D document.addEventListener('keydown', function(e) { if (e.altKey && e.shiftKey && e.key === 'D') { e.preventDefault(); enabled = !enabled; if (enabled) { applyDarkMode(); console.log('[Universal Dark Mode] Enabled'); } else { removeDarkMode(); console.log('[Universal Dark Mode] Disabled'); } } }); // Apply on load applyDarkMode(); // Re-apply on dynamic content var observer = new MutationObserver(function(mutations) { if (enabled && !document.getElementById('universal-dark-mode-style')) { applyDarkMode(); } }); observer.observe(document.head, { childList: true }); console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle'); })(); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })(); GitHub - SiluPanda/convo-tree: Tree-structured conversation state manager for branching chats · GitHub
Skip to content

Repository files navigation

convo-tree

Tree-structured conversation state manager for branching chats.

npm versionnpm downloadslicensenode

convo-tree models a conversation as a rooted tree where each node holds a message (system, user, assistant, or tool), children represent alternative continuations from the same point, and any root-to-leaf path is one complete linear conversation. The core metaphor is git: fork() is git branch, switchTo() is git checkout, getActivePath() is git log --first-parent, and prune() is git branch -D.

The package is a pure data structure with zero runtime dependencies and no network I/O. It manages the tree; the caller manages LLM interactions. Extract the active path with getActivePath(), send it to any LLM provider, and add the response back with addMessage().

Installation

npm install convo-tree

Requires Node.js 18 or later.

Quick Start

import{createConversationTree}from'convo-tree';// Create a tree with an automatic system prompt root nodeconsttree=createConversationTree({systemPrompt: 'You are a helpful assistant.',});// Build a conversation by appending messagestree.addMessage('user','Hello!');tree.addMessage('assistant','Hi there! How can I help?');tree.addMessage('user','Tell me a joke.');tree.addMessage('assistant','Why did the chicken cross the road?');// Extract the active path as a flat message array for any LLM APIconstmessages=tree.getActivePath();// [// { role: 'system', content: 'You are a helpful assistant.' },// { role: 'user', content: 'Hello!' },// { role: 'assistant', content: 'Hi there! How can I help?' },// { role: 'user', content: 'Tell me a joke.' },// { role: 'assistant', content: 'Why did the chicken cross the road?' }// ]

Features

  • Branching conversations -- Fork at any point to explore alternative continuations. Multiple branches coexist in a single tree structure.
  • HEAD tracking -- A HEAD pointer tracks the current position. New messages append as children of HEAD, and HEAD advances automatically.
  • Active path extraction -- getActivePath() returns a flat Message[] from root to HEAD, ready to send to any LLM API.
  • Undo/redo -- Navigate backward and forward along the active path without losing history. Adding a new message after undo implicitly creates a new branch.
  • Subtree pruning -- Remove a node and all its descendants in one operation. HEAD relocates automatically if it falls within the pruned subtree.
  • Branch labels -- Assign human-readable labels to branches for organization (e.g., "creative approach", "model: GPT-4o").
  • Node metadata -- Attach arbitrary key-value data to any node (model name, temperature, latency, token count).
  • Event system -- Subscribe to message, fork, switch, and prune events for reactive UI updates and logging.
  • Serialization -- Export the full tree state as a JSON-serializable object for persistence and restoration.
  • Zero dependencies -- Pure data structure using only built-in Node.js APIs (crypto.randomUUID, Date.now).
  • Full TypeScript support -- Written in TypeScript with exported type declarations.

API Reference

createConversationTree(options?)

Factory function that creates and returns a ConversationTree instance.

import{createConversationTree}from'convo-tree';consttree=createConversationTree({systemPrompt: 'You are a helpful assistant.',now: ()=>Date.now(),generateId: ()=>crypto.randomUUID(),});

Options

OptionTypeDefaultDescription
systemPromptstringundefinedIf provided, a system-role node is created automatically as the root.
treeMetaRecord<string, unknown>undefinedArbitrary metadata to associate with the tree itself.
now() => numberDate.nowCustom timestamp function used for createdAt on every new node.
generateId() => stringcrypto.randomUUIDCustom ID generator for node IDs.

tree.addMessage(role, content, metadata?)

Appends a new message node as a child of the current HEAD and advances HEAD to the new node. Clears the redo stack.

Parameters:

ParameterTypeDescription
role'system' | 'user' | 'assistant' | 'tool'The message role.
contentstringThe message content.
metadataRecord<string, unknown>Optional metadata to attach to the node. Defaults to {}.

Returns:ConversationNode -- the newly created node.

constnode=tree.addMessage('user','Hello!',{tokens: 3});// node.id -> unique UUID// node.role -> 'user'// node.content -> 'Hello!'// node.parentId -> ID of the previous HEAD node (or null if first node)// node.children -> []// node.metadata -> { tokens: 3 }// node.createdAt -> timestamp from now()

When called on a node that already has children, the new message becomes a sibling, creating an implicit fork without requiring an explicit fork() call.


tree.fork(nodeId?, label?)

Marks a fork point in the tree. Does not create a new node. If nodeId is provided, that node becomes the fork point; otherwise the current HEAD is used. Optionally assigns a branch label to the fork point node.

Parameters:

ParameterTypeDescription
nodeIdstringOptional. The node ID to fork from. Defaults to the current HEAD.
labelstringOptional. A human-readable label to assign to the fork point node.

Returns:Branch -- an object with forkPointId and optional label.

Throws:InvalidOperationError if the tree is empty. NodeNotFoundError if nodeId does not exist.

constbranch=tree.fork(someNode.id,'alternate-response');// branch.forkPointId -> someNode.id// branch.label -> 'alternate-response'

After calling fork(), use switchTo() to move HEAD to the fork point, then call addMessage() to diverge from the original path.


tree.switchTo(nodeId)

Moves HEAD to any existing node in the tree, changing the active path to the root-to-node path.

Parameters:

ParameterTypeDescription
nodeIdstringThe ID of the node to switch to.

Returns:void

Throws:NodeNotFoundError if the node does not exist.

tree.switchTo(earlierNode.id);// HEAD is now at earlierNode// getActivePath() returns root -> ... -> earlierNode

tree.getActivePath()

Returns the linear message array from root to the current HEAD. The returned array is suitable for direct use with any LLM chat completion API.

Returns:Message[] -- an array of { role, content, ...metadata } objects. Returns an empty array if the tree is empty.

constmessages=tree.getActivePath();// messages[0].role -> 'system' (if systemPrompt was set)// messages[0].content -> 'You are a helpful assistant.'

Metadata fields are spread into the message object. For example, if a node has metadata: { tokens: 5 }, the corresponding message will include tokens: 5 alongside role and content.


tree.getPathTo(nodeId)

Returns the linear message array from root to the specified node, without changing HEAD.

Parameters:

ParameterTypeDescription
nodeIdstringThe ID of the target node.

Returns:Message[]

Throws:NodeNotFoundError if the node does not exist.

constpathA=tree.getPathTo(responseA.id);constpathB=tree.getPathTo(responseB.id);// Compare two branch paths without switching HEAD

tree.undo()

Moves HEAD to its parent node, pushing the current HEAD onto the redo stack. Returns the new HEAD node, or null if HEAD is already at the root or the tree is empty.

Returns:ConversationNode | null

tree.addMessage('user','First');tree.addMessage('assistant','Second');constprevious=tree.undo();// previous.content -> 'First'// tree.getHead().content -> 'First'

tree.redo()

Restores the most recently undone node by popping the redo stack and advancing HEAD. Returns the restored node, or null if the redo stack is empty or invalid.

The redo stack is validated: the node to redo must be a child of the current HEAD. If the tree structure has changed (e.g., via addMessage() or prune()), the redo stack is cleared.

Returns:ConversationNode | null

tree.undo();constrestored=tree.redo();// HEAD is back at the node that was undone

Adding a new message after undo() clears the redo stack, creating an implicit new branch from the undo point.


tree.getHead()

Returns the current HEAD node, or null if the tree is empty.

Returns:ConversationNode | null

consthead=tree.getHead();if(head){console.log(head.role,head.content);}

tree.getNode(nodeId)

Retrieves any node in the tree by its ID.

Parameters:

ParameterTypeDescription
nodeIdstringThe ID of the node to retrieve.

Returns:ConversationNode | undefined

constnode=tree.getNode('some-uuid');if(node){console.log(node.children.length,'children');}

tree.prune(nodeId)

Removes the specified node and all of its descendants from the tree. Updates the parent's children array. If HEAD falls within the pruned subtree, HEAD is moved to the pruned node's parent. If the root is pruned, the tree is fully cleared.

Parameters:

ParameterTypeDescription
nodeIdstringThe ID of the node to prune.

Returns:number -- the count of nodes removed (including the target node and all descendants).

Throws:NodeNotFoundError if the node does not exist.

constn1=tree.addMessage('user','Root');constn2=tree.addMessage('assistant','Child');tree.addMessage('user','Grandchild');constremoved=tree.prune(n2.id);// removed -> 2 (Child + Grandchild)// HEAD automatically moves to n1

Entries in the redo stack that reference pruned nodes are also removed.


tree.setLabel(nodeId, label)

Sets or updates the branch label on a node.

Parameters:

ParameterTypeDescription
nodeIdstringThe ID of the node to label.
labelstringThe label to assign.

Returns:void

Throws:NodeNotFoundError if the node does not exist.

tree.setLabel(node.id,'creative-approach');// tree.getNode(node.id).branchLabel -> 'creative-approach'

tree.clear()

Resets the tree to an empty state. All nodes, the root, HEAD, and the redo stack are cleared.

Returns:void

tree.clear();// tree.nodeCount -> 0// tree.getHead() -> null// tree.getActivePath() -> []

tree.serialize()

Exports the full tree state as a plain JSON-serializable object.

Returns:TreeState

conststate=tree.serialize();// {// version: 1,// nodes: { 'uuid-1': { ... }, 'uuid-2': { ... } },// rootId: 'uuid-1',// headId: 'uuid-2',// redoStack: []// }// Persist to disk, database, or transmit over the networkconstjson=JSON.stringify(state);

tree.nodeCount

A readonly property returning the total number of nodes in the tree.

Type:number

console.log(tree.nodeCount);// 5

tree.on(event, handler)

Subscribes to tree events. Returns an unsubscribe function.

Parameters:

ParameterTypeDescription
eventstringThe event name: 'message', 'fork', 'switch', or 'prune'.
handlerFunctionThe callback invoked when the event fires.

Returns:() => void -- call this function to unsubscribe.

Events

EventPayloadFires when
messageConversationNodeaddMessage() creates a new node.
forkBranchfork() is called.
switchstring (nodeId)switchTo() moves HEAD.
prune{ nodeId: string, count: number }prune() removes nodes.
constunsub=tree.on('message',(node)=>{console.log('New message:',node.role,node.content);});tree.addMessage('user','Hello');// triggers handlerunsub();// stop listeningtree.addMessage('user','World');// handler is NOT called

Types

All types are exported from the package entry point.

importtype{ConversationNode,ConversationTree,ConversationTreeOptions,Branch,Message,TreeState,}from'convo-tree';

ConversationNode

interfaceConversationNode{id: string;role: 'system'|'user'|'assistant'|'tool';content: string;parentId: string|null;children: string[];createdAt: number;metadata: Record<string,unknown>;branchLabel?: string;}

Branch

interfaceBranch{forkPointId: string;label?: string;}

Message

interfaceMessage{role: string;content: string;[k: string]: unknown;}

TreeState

interfaceTreeState{nodes: Record<string,ConversationNode>;rootId: string|null;headId: string|null;redoStack: string[];version: 1;}

ConversationTreeOptions

interfaceConversationTreeOptions{systemPrompt?: string;treeMeta?: Record<string,unknown>;now?: ()=>number;generateId?: ()=>string;}

Configuration

Custom ID Generator

Supply a deterministic ID generator for reproducible tests or when UUIDs are not desired.

letcounter=0;consttree=createConversationTree({generateId: ()=>`msg-${++counter}`,});constn1=tree.addMessage('user','Hello');// n1.id -> 'msg-1'

Custom Timestamp

Supply a custom clock for deterministic timestamps in tests or when using a different time source.

consttree=createConversationTree({now: ()=>1700000000000,});constnode=tree.addMessage('user','Hello');// node.createdAt -> 1700000000000

Error Handling

convo-tree exports three error classes, all extending from ConvoTreeError.

import{ConvoTreeError,NodeNotFoundError,InvalidOperationError,}from'convo-tree';

ConvoTreeError

Base error class. Has a code property (string) for programmatic error handling.

try{tree.switchTo('nonexistent');}catch(err){if(errinstanceofConvoTreeError){console.log(err.code);// 'NODE_NOT_FOUND'}}

NodeNotFoundError

Thrown when an operation references a node ID that does not exist in the tree. Has a nodeId property indicating which ID was not found.

  • Code:'NODE_NOT_FOUND'
  • Thrown by:switchTo(), getPathTo(), prune(), setLabel(), fork() (when nodeId is provided)
try{tree.getPathTo('does-not-exist');}catch(err){if(errinstanceofNodeNotFoundError){console.log(err.nodeId);// 'does-not-exist'}}

InvalidOperationError

Thrown when an operation is structurally invalid given the current tree state.

  • Code:'INVALID_OPERATION'
  • Thrown by:fork() when called on an empty tree
constemptyTree=createConversationTree();try{emptyTree.fork();}catch(err){if(errinstanceofInvalidOperationError){console.log(err.message);// 'Cannot fork an empty tree'}}

Advanced Usage

Branching Conversations

Fork at any point to explore alternative continuations, then switch between branches.

consttree=createConversationTree();constquestion=tree.addMessage('user','What is the capital of France?');constresponseA=tree.addMessage('assistant','Paris.');// Fork back to the question and try a different responsetree.fork(question.id,'detailed-response');tree.switchTo(question.id);constresponseB=tree.addMessage('assistant','The capital of France is Paris.');// Extract each branch independentlyconstpathA=tree.getPathTo(responseA.id);// [{ role: 'user', content: 'What is the capital of France?' },// { role: 'assistant', content: 'Paris.' }]constpathB=tree.getPathTo(responseB.id);// [{ role: 'user', content: 'What is the capital of France?' },// { role: 'assistant', content: 'The capital of France is Paris.' }]

Undo/Redo with Implicit Branching

Calling addMessage() after undo() creates a new branch from the undo point and clears the redo stack.

consttree=createConversationTree();tree.addMessage('user','First');tree.addMessage('assistant','Second');tree.addMessage('user','Third');tree.undo();// HEAD at 'Second'tree.undo();// HEAD at 'First'// New message creates a branch from 'First'tree.addMessage('assistant','Alternative second');// redo() now returns null -- redo stack was cleared

Serialization and Persistence

Serialize the tree for storage and reconstruct later.

// Saveconststate=tree.serialize();constjson=JSON.stringify(state);fs.writeFileSync('conversation.json',json);// Loadconstloaded=JSON.parse(fs.readFileSync('conversation.json','utf-8'));// Reconstruct by creating a new tree and replaying messages// from loaded.nodes in createdAt order

Event-Driven Updates

Use the event system for reactive UI updates, logging, or analytics.

consttree=createConversationTree();// Log all new messagestree.on('message',(node)=>{console.log(`[${node.role}] ${node.content}`);});// Track branch creationtree.on('fork',(branch)=>{console.log(`Forked at ${branch.forkPointId}: ${branch.label??'unlabeled'}`);});// Monitor pruningtree.on('prune',({ nodeId, count })=>{console.log(`Pruned ${count} nodes starting from ${nodeId}`);});// React to navigationtree.on('switch',(nodeId)=>{console.log(`Switched HEAD to ${nodeId}`);});

Attaching Metadata

Store per-message provenance data such as model, latency, and token counts.

constnode=tree.addMessage('assistant','Hello!',{model: 'gpt-4o',temperature: 0.7,latencyMs: 450,promptTokens: 128,completionTokens: 12,});// Metadata is included in getActivePath() outputconstmessages=tree.getActivePath();// Last message: { role: 'assistant', content: 'Hello!',// model: 'gpt-4o', temperature: 0.7, latencyMs: 450, ... }

Prompt A/B Testing

Fork at the same point to compare responses from different models or prompt configurations.

consttree=createConversationTree({systemPrompt: 'You are a writing assistant.',});constprompt=tree.addMessage('user','Write a haiku about rain.');constresponseA=tree.addMessage('assistant','Gentle drops descend...');// Fork for a second attempttree.fork(prompt.id,'attempt-2');tree.switchTo(prompt.id);constresponseB=tree.addMessage('assistant','Silver threads of rain...');// Fork for a third attempttree.fork(prompt.id,'attempt-3');tree.switchTo(prompt.id);constresponseC=tree.addMessage('assistant','Clouds weep softly now...');// Compare all three pathsconstpaths=[responseA,responseB,responseC].map((r)=>tree.getPathTo(r.id));

TypeScript

convo-tree is written in TypeScript and ships type declarations alongside the compiled JavaScript. All public types are exported from the package entry point.

import{createConversationTree}from'convo-tree';importtype{ConversationNode,ConversationTree,ConversationTreeOptions,Branch,Message,TreeState,}from'convo-tree';

The ConversationTree interface defines the full shape of the tree object returned by createConversationTree(). Use it for explicit typing when passing tree instances between functions.

functionanalyzeTree(tree: ConversationTree): void{constpath=tree.getActivePath();consthead=tree.getHead();console.log(`${tree.nodeCount} nodes, head at ${head?.id??'empty'}`);}

License

MIT

About

Tree-structured conversation state manager for branching chats

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages