Learning Core internals
Learning Core is a bundled extension (extensions/learning-core, plugin id learning-core). This page documents the contracts an extension author touches: the LessonStore interface, the record lifecycle, placement resolution, the task passes, and the capture path. For operating a deployment see the admin page; for the user-facing feature see Learning.
Bundled extensions carry no openclaw peer dependency - fork-base compatibility is expressed by the package SemVer and the forkBase marker in dist/build-info.json. The Postgres backend requires pg lazily (mirroring core’s own pool construction), so no runtime dependency is added to the package.
Placement resolution lives in core
Backend selection is a core concern, exported from the plugin SDK:DomainStoragePlacement is a discriminated union:
backend wins; else databaseUrl implies postgres; else sqlitePath implies sqlite; else follow the system session store). Do not re-derive “which backend” inside a plugin - a second copy of the rule is exactly the drift this seam exists to prevent. Any plugin needing per-domain placement calls the same function with its own domain; the default Postgres schema derives as plugin_<domain with dashes replaced by underscores>.
The LessonStore contract
One interface, three implementations (SQLite, Postgres, fs-jsonl), one conformance suite. Members:
learning_records with record_type='candidate' - the same table as lessons. listLessons must not return them; searchLessons returns lessons only.
What not to do:
describeBackend() returns { backend, capability } and is what learning.overview surfaces. On the fallback wrapper it reports the delegate currently in use, so a Postgres store that has degraded reports fs-jsonl.
Adding a member means adding it to all three implementations and to the conformance suite. The suite is the contract; an implementation that passes it is portable by construction.
Record lifecycle
lesson-record.ts is the only place that decides lifecycle shape:
withLessonDefaultsstamps a new record with current defaults.applyLegacyLessonDefaultsforward-migrates a pre-C0 record.isLessonActiveevaluatesvalid_from/invalid_atagainst a suppliednow- callers pass the clock in; the function never reads it.
supersedeLesson writes the successor, links supersedes_id, and closes the predecessor’s validity window. Nothing is removed; getLessonChain walks the links. This path has a production caller (the learning tool), so it is not test-only code.
Backend selection and fallback
createLessonStore memoises by resolved location: SQLite databases by path, Postgres pools by URL. Without memoisation the same file would be opened (and the same database pooled) three times, because index.ts builds a store at three call sites.
Fallback differs by backend because the failure surfaces differ:
- SQLite fails at open, so it is wrapped in a
tryand falls back immediately to theFileLearningStore. - Postgres does not connect at construction. It is wrapped in
FirstFailureFallbackLessonStore: the first rejection swaps the delegate and retries that one operation on the fallback so the caller sees success. After the swap, an error is the fallback’s own and propagates.
Task passes
Four built-in passes.registerTaskPass takes the bare id; the backbone namespaces it as <pluginId>.<passId>. The effective ids are stable - do not rename:
The
TaskPass shape is { id, stage?, priority, scope, costClass, timeoutMs?, run } - there is no handler field.
Apply writes under the managed skills dir ($OPENCLAW_STATE_DIR/skills or ~/.openclaw/skills) using a hash-verified tmp + fsync + rename protocol. After a successful rename, a later decision-write failure must not delete the file; reconcile repairs from desiredState.
searchLessons(query, identity, k) uses FTS5 (SQLite), search_tsv + GIN (Postgres), or a substring scan (fs-jsonl, or when the optional search DDL was skipped by the backend). Hyphenated FTS5 queries are tokenized because hyphen is the FTS NOT operator.
Capture path
Learning Core is a session consumer (manifest:effectKind: "session.end", consumeLanes: ["*"]). onSessionWorkAvailable filters to session.end, then loops claimSessionWork until the queue drains:
- When
modeisoff, the handler acknowledges without persisting. - Otherwise it writes an
ExtractionCandidate(only when a transcript excerpt exists) and acksprocessed. It must not callupsertLesson/mergeLesson. - On a processing failure, the catch block logs
api.logger.warn(message, error code, claim and session ids) before acking the claim as failed/retryable - a throwingackSessionWorkmust never mask the real error.
<agentId>:<pluginId>:<effectKind>, with an unscoped <pluginId>:<effectKind> fallback and an optional /<subCursor> suffix. Anything that counts or looks up claims must match all those shapes - equality against a bare learning-core matches nothing.
When mode is assist, register also subscribes to context.collect and prepends ctx:{sessionKey}:lessons.active with up to maxLessonsForPrompt (default 5) active lesson texts. Do not import context-engine from this plugin.
Reading transcript text
Production entries areStoredConversationEntry, which has no content field - text lives in contentText and contentBlocks. An extractor that reads only content yields zero candidates on every real session while the claim path still acks processed, so the failure is completely silent. Two rules follow:
- Fixtures must use the real
StoredConversationEntryshape. A{ role, content }fixture is a shape that never occurs in production and will pass while the code is dead. - Text from storage carries a
[<from> <host> <timestamp>]prefix, trailing space included. Correction patterns must account for it; a bare^-anchored regex can never match.
Gateway and tool seams
Seven gateway methods are registered inindex.ts: learning.overview, learning.listLessons, learning.searchLessons, learning.listProposals, learning.inspectProposal, learning.decideProposal, learning.decideProposals. The read path goes through the store, not the raw JSONL log - leaving the read path on the log while capture moved to SQL would show an empty list while capture silently succeeded.
learning.decideProposal looks a proposal up with an empty scope deliberately (so the lookup spans every workspace), returns the proposal’s own scope in the result, then builds a scoped store from it for the write.
The learning tool is registered per invocation with the caller’s scope and owner flag. Every action is refused unless senderIsOwner is true.
Blocking the agent loop
register is synchronous today. Do not add synchronous blocking I/O (fs.readFileSync, execSync) inside it - that stalls gateway startup. Pass run functions are async and must not block the event loop on LLM or disk work without await.
Do not do this:
"learning-core.promote") throws PLUGIN_REGISTER_ERROR at gateway boot (Plugin task pass id must not contain "."). Register the bare id; the backbone stores the effective id.
Do this instead:
Testing
The conformance suite runs the Postgres store twice: against a live server when
OPENCLAW_DB_URL is set, and against createInMemoryPgPool() - a PgPoolLike double that throws on any statement it does not recognise - on every host. The double is not a substitute for the live leg.
Run the extension’s tests with the standard battery; there is no separate learning-core lane.
Adding a backend
- Implement
LessonStore. - Add the dialect DDL to
learning-schema.ts, keeping identifier validation in that module (the schema name is checked at both SQL interpolation boundaries, and again in thePostgresLessonStoreconstructor, because a caller can construct the store directly and bypass the DDL path). - Add the backend to the union in core’s domain-storage resolver and to
SessionStorageBackendKind, so selection stays in one place. - Add it to
BACKENDSin the conformance suite and make the suite pass unmodified. - Decide the failure surface - open-time or first-operation - and wire the matching fallback shape in the factory.
What’s next
- Learning Core admin - operation, placement, verification
- Analysis runtime - the LLM lane consolidation runs through
- Plugin manifest