@geovanelourenco/conductr-core
v0.4.1
Published
The execution and knowledge engine for AI over your own data: scoped reads through typed capabilities, answers from evidence with any model, and memory with review, versions and search.
Downloads
472
Maintainers
Readme
@geovanelourenco/conductr-core
The execution and knowledge engine for AI over your own data.
- Execution: answers questions by reading your data through typed capabilities, with the caller's scope enforced by your code, every model reply validated, and every fact traced back to the records it came from. Works with any model, small and local or large and hosted.
- Knowledge: remembers what matters between conversations (facts, preferences, goals, procedures) with review, versions, scopes, provenance and full-text search.
Install
npm install @geovanelourenco/conductr-coreRequirements:
- Node.js 20.3 or later.
- An OpenAI-compatible server: one that honours
response_format.schemaforgrounded(llama.cpp'sllama-serverdoes), or one with tool calling foragent. - For the SQLite knowledge store: Node.js 22.5 or later (
node:sqlite), or better-sqlite3.
Tested with Qwen3 1.7B (Q4_K_M) on CPU.
llama-server --hf-repo unsloth/Qwen3-1.7B-GGUF:Q4_K_M --alias local --ctx-size 4096 --port 8080Execution
The engine serves every provider. What a provider can do decides how a question is answered:
grounded, for any provider and the safe choice for small models: the model only makes small, constrained choices and your code does everything else (below).agent, for providers that call tools natively (Gemini, OpenAI, a large local model with a tool-calling template): the model calls your capabilities as tools, as many times as it needs, and writes the answer. The scope, the validation and the limits stay with the runtime.
With strategy: "auto" (the default) the engine picks agent when the model client has chat, and grounded otherwise.
Grounded
- Plan. The model labels the question: a read the capabilities can answer, or one of your refusal labels (writes, secrets, another tenant…). For a read it also lists the periods the question names, so a comparison of two months becomes two reads.
- Read. For each period the model fills a capability input whose JSON Schema you provide: your enums, your ids for this user only, and the period already fixed. Your capability runs the read with the caller's trusted scope.
- Answer. Facts are taken from the rows your capability returned, each with the records it came from, plus the changes between periods. Your
formatAnswerwrites the answer from them. No number in the answer comes from model text.
Every reply is constrained by a JSON Schema, and the model server compiles it into a grammar. A reply that still fails validation goes back to the model with the exact problem.
Usage
import { CapabilityInputError, createConductr, memoryCache, openAiCompatibleModel } from "@geovanelourenco/conductr-core";
const expenses = {
id: "expenses.sum",
version: "v1",
summary: "totals of the family's expenses, optionally per category",
describe: (scope) => `Categories: ${categoriesOf(scope).map((c) => `${c.id} = ${c.name}`).join(", ")}.`,
inputSchema: (scope, hints) => ({
type: "object",
properties: {
category: { enum: ["any", ...categoriesOf(scope).map((c) => c.id)] },
from: hints.period ? { const: hints.period.from } : { type: "string" },
to: hints.period ? { const: hints.period.to } : { type: "string" },
},
required: ["category", "from", "to"],
additionalProperties: false,
}),
async execute(input, { scope }) {
if (!isValid(input)) throw new CapabilityInputError("from and to must be YYYY-MM-DD."); // shown to the model
const rows = await db.sumExpenses({ ...input, familyId: scope.familyId }); // scope from your session
return { rows: [{ total_cents: rows.total, count: rows.count }], recordIds: rows.ids };
},
};
const conductr = createConductr({
models: { planner: openAiCompatibleModel({ url: "http://127.0.0.1:8080" }) },
capabilities: [expenses],
refusals: [
{ label: "changes_data", description: "create, change or delete records" },
{ label: "moves_money", description: "transfers or payments" },
],
formatAnswer: ({ facts }) => facts.map((fact) => `${fact.name}: ${fact.value}`).join("\n"),
cache: memoryCache(),
});
// Stream progress to your client (over SSE, for instance)…
for await (const event of conductr.ask({ question, scope: session.scope, requestId })) {
send(event);
}
// …or just wait for the result.
const result = await conductr.run({ question, scope: session.scope, requestId });Capabilities
interface Capability<TScope> {
id: string;
version: string;
summary: string; // for the planner
describe(scope: TScope): string; // how to fill the input, with reference data the scope may see
inputSchema(scope: TScope, hints: { period?: Period }): JsonSchema;
execute(input: unknown, context: { scope: TScope; signal?: AbortSignal }): Promise<{ rows; recordIds }>;
}Guidelines that matter with small models:
- Prefer closed choices. Use enums of the caller's own ids, and list
"any"first. A small model picks reliably from a list and invents ids when it has to write them. - Match the input to the provider. For small models, a few question-shaped fields that you compile into your query are far more reliable than a free query language. Large models with tool calling handle an open query (filters, grouping, measures) well, and it covers far more questions. The same capability can serve both: the runtime does not care, the model does.
- Shape the rows. Rows without an
idare aggregates: their numbers become facts, labelled by their string values. Rows with anidare records. Leave scope columns out of the rows. - Validate everything in
execute. ThrowCapabilityInputErrorfor inputs the model can fix.
Agent strategy
const model = openAiCompatibleModel({
url: "https://generativelanguage.googleapis.com/v1beta/openai",
model: "gemini-3.5-flash-lite",
apiKey: process.env.GEMINI_API_KEY,
tools: true, // the provider calls tools: the client gets `chat`
disableThinking: false, // llama.cpp's chat_template_kwargs is for local models
reasoningEffort: "low",
});
const conductr = createConductr({
models: { planner: model },
capabilities: [query],
instructions: "Answer in Brazilian Portuguese, in up to four sentences.",
tools: [rememberTool(engine, { scopes })], // optional: anything besides reads
maxRounds: 5, // model turns; the last one must answer
maxToolCalls: 8,
});
for await (const event of conductr.ask({ question, scope, history })) send(event); // `text` events stream the answer- Each capability is offered as a tool (
summaryanddescribe(scope)as its description,inputSchema(scope, {})as its parameters). Invalid JSON, unknown tools andCapabilityInputErrors go back to the model to fix. - The refusal labels become a
refusetool; calling it ends the request asrefused. historycarries earlier turns, for follow-ups, andinstructionsonaskadds rules for that question only (the person's preferred tone, for instance) after the shared ones.- A tool with
requiresApproval: truenever changes anything: itsexecutevalidates the call and returnsproposalitems (id, a one-linesummary, and your owndata). The model is told the changes wait for approval. All the items of an answer, from every call, come together in oneproposalevent beforedone(and inresult.proposal), so the person approves them at once, or some of them; applying them is yours, after approval. - A capability may return
content, what the model reads instead of{ rows }(a compact, already formatted view), andlabel, a short description of the read that comes back inpart_finishedfor the host to show ("expenses in October"). - The answer is the model's text.
factsstill come from the rows each read returned, so a host can check the numbers against them.
Knowledge
@geovanelourenco/conductr-core/knowledge keeps what an assistant learns: facts, preferences, goals, procedures and decisions. It is a module of its own, built as use cases over a storage contract; only knowledge/conductr.ts knows the runtime, so it can move to a separate package.
import { DatabaseSync } from "node:sqlite"; // or better-sqlite3
import { createKnowledgeEngine, knowledgeContext, rememberTool, sqliteKnowledgeStore } from "@geovanelourenco/conductr-core/knowledge";
const engine = createKnowledgeEngine({
store: sqliteKnowledgeStore(db, { prefix: "assistant_memory" }),
// Default: every proposal waits for review. Here a person's own preferences are kept at once (show them with an undo).
autoAccept: (candidate) => candidate.scope.type === "user" && candidate.kind === "preference",
});
const scopes = (session) => ({ person: { type: "user", id: session.userId }, family: { type: "family", id: session.familyId } });
const conductr = createConductr({
// ...
context: knowledgeContext(engine, { scopes: (session) => Object.values(scopes(session)) }),
tools: [rememberTool(engine, { scopes })],
});- Review.
proposerecords a candidate;acceptturns it into knowledge,rejectkeeps the reason. TheautoAcceptpolicy decides what skips review. - Scopes. Every read takes the scopes the caller may see, from your session. The
remembertool offers the model scope labels (person,family), never ids. - Versions. A proposal with
supersedesbecomes the next version of that entry;historylists them all.forgetstops recalling an entry and keeps the record. - Provenance and validity. Each entry keeps where it came from, and
validUntilretires a goal after its deadline. Recalled knowledge is labelled as possibly outdated: data read now wins. - Decisions.
recordDecisionkeeps how a person decided on a proposal (approved, rejected or in part, with the reason), at once and whatever the review policy, so later answers recall it throughknowledgeContextand stop proposing what was turned down. - Search. SQLite FTS5, ignoring accents and matching prefixes, ranked by bm25. Without FTS5 the store scans the text. The index is a projection:
rebuildIndexrecreates it from the canonical tables. - Storage.
sqliteKnowledgeStoretakes any connection withexecandprepare(Node'snode:sqlite, better-sqlite3) and creates its tables under a prefix. ImplementKnowledgeStorefor another database.
Learning
Knowledge about the people the assistant serves makes it fit each of them; learning makes the assistant itself better at its job. createLearner turns what happened in an answer, and what came back about it, into lessons for the agent: rules of kind procedure, in a scope the host keeps for the agent and shares with everyone it serves.
import { combineContext, createLearner, episodeRecorder, knowledgeContext } from "@geovanelourenco/conductr-core/knowledge";
const learner = createLearner({
model, // the model that reflects (any client)
engine,
scope: { type: "agent", id: "finance" }, // the agent's own lessons, never a person's scope
instructions: "A family finance assistant. consultar reads entries; propor_alteracoes prepares changes.",
confirmations: 3, // optional: accepted on its own once 3 different origins confirm it
});
const conductr = createConductr({
// ...
context: combineContext(knowledgeContext(engine, { scopes }), learner.context()),
});
// While answering: keep what the agent did, with the message.
const recorder = episodeRecorder(question);
for await (const event of conductr.ask({ question, scope })) { recorder.observe(event); send(event); }
save(messageId, recorder.episode());
// When feedback arrives (minutes or days later):
await learner.learn({
episode: load(messageId),
signal: { type: "rating", rating: "down", comment: "I wanted to recategorize them, not create an entry" },
origin: hash(tenantId),
});- Episode.
episodeRecorderbuilds, from the events of oneask, a compact JSON of the request, each read or tool call with its input (and whether it was rejected), the changes prepared and the answer. The host keeps it until feedback comes. - Signals. A rating (
upordown, with an optional comment), a decision on a proposal (approved,rejected,partial, with the reason) or a correction: the person's next message, when it looks like it says the answer missed. It need not be sure: a message that turns out to be a follow-up or a new request teaches nothing. - Reflection. One model call compares what was asked, what was done and what came back, with the related lessons already known, and decides: a new lesson, the same as a known one, a correction of a known one (its next version, through
supersedes), or none: taste, tone, a problem with the data, vague feedback or a right answer teach nothing. Lessons are written as general rules, without names, amounts, dates or anything that identifies who asked. A reply it cannot use goes back once with the problem. - Review. Lessons are proposals: they wait for a person (
learner.pending,learner.accept,learner.reject) unless the engine'sautoAccepttakes them. Withconfirmations, a lesson confirmed by that many differentorigins is accepted on its own; confirmations are kept as proposals pointing at the lesson and settled when it is decided. - Recall.
learner.context()adds the accepted lessons that match the question to the prompt;combineContextputs them after what the assistant knows about the person.
Results and events
run resolves, and ask ends with { type: "done", result }. The result is one of three shapes:
completed:strategy,answer,facts(each withrecordIdsandsource) andparts.partsholds what was read and with which input; you can show that interpretation to the user and let them correct it.refused:labelandreason.failed:error.code, one ofmodel_unavailable,invalid_model_output,capability_failedoraborted.
Every result also carries usage (model calls, corrections, tokens) and durationMs.
Events, in order: strategy; in grounded, planned, then for each part part_started, query (and query_rejected when the capability refuses an input), and part_finished; in agent, the same part events for each read, text as the answer is written and tool for other tools; then done.
Retrying
Everything is read-only, so resending a failed question is safe. With a cache and a requestId, resending a question that already finished returns the stored result without calling the model. Failed results are not cached.
Options
| Option | Default | |
|---|---|---|
| models.planner / models.worker | — / planner | a larger model can plan while a smaller one reads |
| refusals | DEFAULT_REFUSALS | labels the planner may refuse with |
| periods | true | split questions into the periods they name |
| formatAnswer | one line per fact | write the answer in your language and number format |
| corrections | 2 | invalid replies sent back to the model |
| queryRetries | 2 | inputs a capability may reject before a part fails |
| cache | none | memoryCache({ ttlMs }), or your own { get, set } |
| now | new Date() | the reference date for months without a year |
| strategy | auto | grounded, agent, or by provider |
| context | none | extra context per question, such as knowledgeContext |
| instructions | none | agent: the host's rules, added to the system prompt |
| tools | none | agent: tools besides the capabilities |
| maxRounds / maxToolCalls | 5 / 8 | agent: model turns and calls per question |
| maxResultChars | 8000 | agent: longest tool result sent to the model |
openAiCompatibleModel({ url, model, timeoutMs = 60000, temperature = 0.2, disableThinking = true, apiKey, tools = false, structuredOutput = "llama", reasoningEffort }) builds a model client. tools: true adds chat; structuredOutput: "json_schema" asks hosted providers for a schema the OpenAI way.
When the model answers HTTP 429 or 5xx, the client tries it again (retries, default 1, retryDelayMs apart) and then each of fallbackModels in order. An overloaded model is skipped for cooldownMs (default 5 minutes), so later requests go straight to the fallbacks. onRetry reports each switch, for your logs. Implement ModelClient to use any other server.
