@jsilvanus/chattydeer
v0.5.1
Published
A Node.js minimal LLM chat completionist — wraps embedeer with Explainer and LLM adapter capabilities
Maintainers
Readme
chattydeer

A Node.js chat completions toolkit
A Node.js LLM chat toolkit built on top of @jsilvanus/embedeer.
Provides Explainer, LLMAdapter, ChatSession, prompt utilities, and higher-level agentic helpers for multi-turn tool-calling.
New additions include createChatProvider, runAgentLoop, and createOpenAiChatHandler (OpenAI /v1/chat/completions handler).
Install
npm install @jsilvanus/chattydeerUsage
Single-turn explanation
import { Explainer } from '@jsilvanus/chattydeer';
const explainer = await Explainer.create('llama-3.2-3b', { deterministic: true }); // deterministic: stable, reproducible outputs (useful for tests)
const result = await explainer.explain({
task: 'narrate', // intent: what you want the explainer to produce (e.g. 'summarize', 'narrate')
domain: 'evolution', // domain helps choose domain-specific phrasing or templates
context: { filePath: 'src/auth/handler.ts' }, // optional contextual metadata (file path, URL, etc.)
evidence: [
// Evidence items: structured blocks the explainer will reason over
{ id: 1, source: 'src/auth/handler.ts', excerpt: '2024-03-15 *** LARGE CHANGE' },
],
maxTokens: 256, // limit the response length (tokens)
});
console.log(result.explanation); // "The auth handler underwent a major rewrite..."
await explainer.destroy();Multi-turn chat with function calling (agentic loop)
import { ChatSession } from '@jsilvanus/chattydeer';
const session = await ChatSession.create('llama-3.2-3b', {
systemPrompt: 'You are a gitsema guide assistant. Use tools to answer questions.',
tools: [
{
name: 'semantic_search',
description: 'Search the codebase semantically by natural-language query.',
parameters: {
type: 'object',
properties: { query: { type: 'string' } },
required: ['query'],
},
},
{
name: 'recent_commits',
description: 'Return the N most recent commits touching a file.',
parameters: {
type: 'object',
properties: {
filePath: { type: 'string' },
n: { type: 'number' },
},
required: ['filePath'],
},
},
],
});
// The session runs an agentic loop:
// LLM requests tool calls → ChatSession executes them → results fed back → repeat
const answer = await session.send('Which files changed most in the last month?', {
executeTool: async (name, args) => {
if (name === 'semantic_search') return mySearch(args.query);
if (name === 'recent_commits') return myCommits(args.filePath, args.n);
throw new Error(`Unknown tool: ${name}`);
},
maxIterations: 10, // safeguard against infinite loops
});
console.log(answer);
// Continue the conversation in the same session
const followUp = await session.send('Can you summarise the top file in one sentence?', {
executeTool: async (name, args) => { /* ... */ },
});
await session.destroy();Preloading / warming a model
To reduce the latency of the first request you can preload a model into the underlying embedding/generation layer. Two common options:
- Use
@jsilvanus/embedeerdirectly to download and cache a model ahead of time:
import { loadModel } from '@jsilvanus/embedeer';
// downloads and caches the model files so subsequent creation is fast
await loadModel('Xenova/all-MiniLM-L6-v2', { token: process.env.HF_TOKEN });- Or create and initialize a
LLMAdapter/ChatSessiononce at startup and reuse it:
import { LLMAdapter, ChatSession } from '@jsilvanus/chattydeer';
// warm a generator pipeline (keeps it in memory)
const adapter = await LLMAdapter.create('llama-3.2-3b', { token: process.env.HF_TOKEN });
// reuse adapter for sessions
const session = await ChatSession.create('irrelevant', { adapter });
// later: session.send(...)Both approaches reduce cold-start latency. Use loadModel() when you only need to ensure model artifacts are present on disk; use LLMAdapter.create() when you want an initialized in-process generator ready to serve requests.
API
Explainer
Explainer.create(modelName, opts)— create an explainer bound to a modelexplainer.explain(request)— explain using structured evidence blocks; returns{ explanation, labels, references, meta }explainer.destroy()— release underlying resources
ChatSession
ChatSession.create(modelName, opts)— create a session; acceptstools,systemPrompt,adaptersession.send(userMessage, opts)— send a message and run the agentic tool-call loop; returns a final plain-text answeropts.executeTool—async (name, args, callId) => result— called for each tool the LLM requestsopts.maxIterations— loop guard (default10)opts.maxTokens— tokens per LLM call (default512)
session.history— read-only snapshot of allChatMessageobjects in the conversationsession.destroy()— release underlying resources
Additional ChatSession notes:
session.append(msg)— append aChatMessageto the session (useful for programmatic injection of tool or system messages).- Chat history is kept in-memory on the
ChatSessioninstance (the_historyarray).session.historyreturns a snapshot copy. There is no built-in durable persistence; to persist history, serializesession.historyyourself and replay or rehydrate into a newChatSession.
LLMAdapter
LLMAdapter.create(modelName, opts)— low-level text-generation adapteradapter.generate(prompt, opts)— returns{ text, raw, meta }adapter.destroy()— release underlying resources
Utilities
explainForGitsema(payload, opts)— gitsema-compatible adapterrenderTemplate(domain, vars)— render a domain-specific prompt templateestimateTokensFromChars(chars)/trimEvidenceForBudget(prelude, evidence, budget)— prompt utilities
Agentic helpers
createChatProvider(httpUrl, model, apiKey?, opts?)— factory that returns aChatCompletionProviderwithcomplete()andstream()methods for any OpenAI-compatible endpoint (OpenAI, Ollama/v1, vLLM, llama.cpp server).optsacceptstimeoutMs(default 60000) andmaxRetries(default 2, applied to HTTP 429/5xx with exponential backoff). Passsignalon the request to support cancellation.runAgentLoop(session, opts)— run an agentic tool-calling loop using aChatCompletionProvider. Options includeprovider,tools,executeTool(call: ToolCall): Promise<string>,maxRoundtrips(default 5),maxTokens,temperature,onMessage, andredactContent(a hook applied to every message's content — including tool results — before it leaves the process). Per-tool-call errors becomerole: 'tool'result messages instead of crashing the loop. IfmaxRoundtripsis exhausted before a final text answer, the loop returns the best-available answer and history rather than throwing.createOpenAiChatHandler(provider, tools?, executeTool?, opts?)— returns an ExpressRequestHandlerimplementing a lightweight subset of OpenAI'sPOST /v1/chat/completionsAPI that maps requests torunAgentLoop. The handler honors theredactContenthook and is suitable for mounting inside an existing HTTP server.createAgentSession(opts?)— returns a minimal{ history, append(msg) }session object accepted byrunAgentLoopandcreateChatProvider, without requiring an LLM adapter. AcceptssystemPromptand an initialmessagesarray.
OpenAI tool-calling wire format
ChatMessage/ToolCall objects are mapped to OpenAI's chat-completions shape on the wire:
- Tool definitions passed via
toolsare sent asbody.tools: [{ type: 'function', function: { name, description, parameters } }](the modern OpenAI/Ollama/vLLM tool-calling shape). - An assistant message with
toolCallsis sent as{ role: 'assistant', content, tool_calls: [{ id, type: 'function', function: { name, arguments: JSON.stringify(args) } }] }. - A
role: 'tool'result message (withtoolCallIdset to the correspondingToolCall.id) is sent as{ role: 'tool', tool_call_id, content }. - Responses are parsed back from
message.tool_calls(or the legacyfunction_call) intoChatMessage.toolCalls, each with a generatedidif the server didn't provide one.
This means any OpenAI-compatible endpoint (OpenAI, Ollama, vLLM, llama.cpp server) can be used as the provider for runAgentLoop.
Agent loop example
import { createChatProvider, runAgentLoop, createAgentSession } from '@jsilvanus/chattydeer';
const provider = createChatProvider('http://localhost:11434', 'llama3.1', undefined, { timeoutMs: 30_000 });
const session = createAgentSession({
systemPrompt: 'You are a helpful assistant with access to tools.',
});
session.append({ role: 'user', content: 'What does the auth module do?' });
const tools = [
{ name: 'semantic_search', description: 'Search the codebase semantically', parameters: { type: 'object', properties: { query: { type: 'string' } }, required: ['query'] } },
];
const result = await runAgentLoop(session, {
provider,
tools,
maxRoundtrips: 5,
redactContent: (text) => text.replace(/sk-[a-zA-Z0-9]+/g, '[REDACTED]'),
async executeTool(call) {
if (call.name === 'semantic_search') return JSON.stringify(await mySearch(call.arguments.query));
return `Unknown tool: ${call.name}`;
},
});
console.log(result.answer);
await provider.destroy();See explainer-contract.md for the full Explainer interface contract.
License
MIT
