dsh-lesson-loop
jayyuen666/dsh-lesson-loop
Turns guard denials, gate failures and human thumb-downs into project lessons, and injects only the rules a human has armed into each new session of that project; it registers no model tools and is reviewed through its settings card.
Install
dsh plugin --profile web add github:jayyuen666/dsh-lesson-loopREADME
dsh-lesson-loop
Guard denials, gate failures and human thumb-downs become project lessons; only a rule a human confirmed is injected back into the model at session start.
A self-evolution loop for dsh: one lesson bus the guard plugins report to, one rule library a human curates, and armed rules injected into every new session of their project.
Contents
- What it does
- Install
- Verify it is live
- What it does not provide
- Enabling it in dsh
- The lesson bus
- Settings
- Public interface
- Data and privacy
- Known boundaries
- FAQ
- Development
- License
What it does
- One bus, no coupling. Sibling plugins report through the optional
lessonLoopservice; a profile without this package only drops those reports. The host half provides the service, the client half renders a web card holding the switches and the rule review desk. - Sink. Each report appends one line to the event stream and merges into a candidate card keyed by (project, category, signature); at
promoteThresholdthat key is marked ready for review. - A machine never promotes a rule. Status
armedis only ever a human decision — the card'sarmaction writes it, and the startup migration merely carries an existing armed state onto a merged card.reject/demote/archive/edit/reviveare human actions too. - Remind. A project-agnostic standing section keeps the loop visible, and each root session receives the full text of its project's armed rules at
agent/created. Subagent sessions are skipped. - Zero model tools, zero HTTP routes of its own. The card's data plane is the official Typert Remote namespace
lessonLoopRules, reached through the host/apigateway.
How a rule is judged
- Three counters answer three different questions:
violation(the scenario fired and was broken),suppressed(the scenario fired and was followed, driven bypass),samples(the rule was present and not violated). - The only ratio is
violation / (violation + suppressed);samplesis exposure and never a denominator. samplescounts a durable session once even when it is resumed: duplicate disposal edges are deduplicated, so reopening a session does not inflate exposure.- Demotion needs a positive
suppressed,violationatdemoteThreshold, real evidence atdemoteMinSamples, and the ratio atdemoteRatio. - A rule with zero clean hits whose scenario keeps firing is undeterminable: neither the ratio nor the decay can retire it, so it stays armed for a human. One
passclears the flag.
Install
dsh plugin --profile web add @jayyuen66/dsh-lesson-loop
Remove with dsh plugin --profile web remove @jayyuen66/dsh-lesson-loop. The package is on the public npm registry, so installation needs no credentials.
- Node.js
>= 22.12, hostdshon the0.2.1-rc.3line. The requirement the host reads isengines.dsh(^0.2.1-rc.3), and it decides alone — with that field present,evaluatePluginCompatibilitynever looks at the@deepseek-ai/dsh*peers. Measured:0.2.0-rc.2and0.2.1-rc.1are refused,0.2.1-rc.3and0.2.2pass,0.3.0is refused. - No
@deepseek-ai/*underdependencies, by contract. Profiles install withnodeLinker: hoistedandautoInstallPeers: false, so a host package there becomes a real second copy;@deepseek-ai/dsh-toolskeys its scheduler with a plainSymbol, so every native tool call throws in that profile while build, typecheck and coverage stay green. The four host packageshost.jsimports for value are required peers and repeated underdevDependenciesat the same exact pin, so the bundler externalizes them instead of inlining the official function bodies;test/publish-manifest.test.tsenforces both halves. Artifacts are rebuilt byprepack, never committed.
Shared version floor
If your profile pins @jayyuen66/dsh-plugin-shared with a pnpm overrides entry, that pin must be at least the version this package declares. A lower pin installs a copy without the names imported at runtime, and the host then reports N entries did not activate together with does not provide an export named ... at boot — the package installs fine and never loads, and no per-repository CI run can see it.
Verify it is live
- The layer is mounted.
dsh --profile web --dump-configends with# == @jayyuen66/dsh-lesson-loopplus- id: lesson-loop.- That command is the only accepted signal, and it is not read-only — it rewrites the profile's root assembly file. Read layer lines from stdout and check stderr separately: an unmatched patch target appears only on stderr, so the exit code alone proves nothing. Accept both
# == <layer>and# == <layer>, patched by <path>.
- That command is the only accepted signal, and it is not read-only — it rewrites the profile's root assembly file. Read layer lines from stdout and check stderr separately: an unmatched patch target appears only on stderr, so the exit code alone proves nothing. Accept both
- The card renders live data. Its header carries the current candidate / armed / lesson counts and opening it fills the rule groups. An error row reading "stats unavailable" means the layer is mounted but the Remote namespace did not reach the browser.
- A lesson reaches the library. Trigger one denial or gate failure from an installed reporter (danger-guard, quality-gate, session-rescue), or run
/lessons-digestin a session with negative feedback; the candidate group gains evidence. - An armed rule comes back. Arm a card, then start a new session in that project: the injected frame begins with
[lesson-loop] Experience rules for this project. Subagent sessions receive nothing, by design. - The profile carries no host copy.
node_modules/in a scratch profile holds@jayyuen66/and no@deepseek-ai/directory.
What it does not provide
- No model tools. There is no
lesson_reporttool; the surface is the service plus the card's namespace. - No HTTP route of its own. Authentication, Host / DNS-rebinding / Origin checks and body limits are the host gateway's.
- No CLI subcommand. The only command it contributes is the in-session
/lessons-digest. - No outbound request of its own. The distillation call goes through the host
llmservice. - No self-re-enabling rules. A
rejectedcard may return tocandidateat the revive threshold; only a human can arm it again.
Enabling it in dsh
dsh.bundle.patchpoints atcordis.patch.yml, whose single registration line is- id: lesson-loopwithname: "@jayyuen66/dsh-lesson-loop". The bare package name is what makes the host scan the client half.- One entry, one
Config. The settings namespace is the entry id, so switches, thresholds and therulesarray share one section and one revision.rulescarries.loose(), the read side checks the raw shape throughdescribe().user(unreadable means it refuses to write rather than overwriting), and every write is one CAS commit with re-read and replay. settingsis the only plugin-level hard dependency.timer/commands/systemPrompt/llmare optional: a missing one removes that capability, the plugin still loads.- The card's data plane is installed unconditionally on the apply fiber, so it exists even on a host whose web layer arrives late.
- Deployment defaults go on the registration line's
config:(card value > rowconfig> schema default); an invalid config fails this entry's fiber alone. - An incompatible host is granted per exact version, never by widening a range:
dsh plugin --profile web allow-version @jayyuen66/dsh-lesson-loop@<version> --dsh-version <exact running host version> --accept-risk
version-exemptions lists the grants and revoke-version removes one; --dsh-version must equal the version the running host reports.
The lesson bus
| Method | Purpose |
|---|---|
report(input) | Append a lesson, merge it into that key's card; returns { ok, reason?, violationOf?, candidate?, ready?, justReady? } |
pass(input) | The rule's scenario fired and was followed; counted as suppressed when the session ends |
rules() / recentLessons(project?, limit?) | Read the rule library / the event stream |
ruleAction(id, action, statement?) | The six human actions |
stats() | Counters and the config summary behind the card |
reasonis one ofdisabled,rules-not-persisted(this change did not reach the library — retry) orerror. It is a receipt code, not translated text.sourceis an open set:danger-guard,quality-gate,session-rescue,lessons-digestandmanualare built in, and a third party may name itself freely (trimmed, non-empty, ≤ 64 characters, elsemanualwith a log line). Two report kinds carry meaning worth naming:gate-not-runmarks a check that never ran, because an environment problem must not be learned as a code lesson, andneedsUserConfirmation: truein the evidence marks a judgement the reporter could not make — evidence, but no violation.- Path-shaped categories fold into category-level keys (
edit-before-factgate,edit-before-secret-path,dangerous-bash), so one general lesson does not shatter into per-file cards. The concrete target stays in the evidence:signatureforsecret-pathanddangerous-bash,targetforfactgate-deny(which reports the folded key as its signature). - Callers should go through
settleLessonCallfrom@jayyuen66/dsh-plugin-shared/lib/lesson-bus, which routes sync throws and async rejections to one exit.
Settings
Namespace = the entry id lesson-loop. Four switches default to true: enabled (master — off means no reporting, injection, exit settlement or standing section, and /lessons-digest refuses), reportEnabled, injectEnabled, sectionEnabled.
| Field | Default | Range | Purpose |
|---|---|---|---|
promoteThreshold | 3 | 1–20 | Same-key lessons reaching this count mark the card for review |
demoteThreshold | 3 | 1–50 | Violations after arming required to demote |
demoteMinSamples | 5 | 1–100 | Minimum real evidence violation + suppressed |
demoteRatio | 0.5 | 0.05–1 (step 0.05) | Recurrence-ratio red line |
decayDays | 30 | 1–365 | Armed this long without a clean hit turns the card undeterminable; later violations do not reset the window |
reviveThreshold | 3 | 1–20 | A rejected rule seen this often returns to candidates |
maxLessonsBytes | 0 | 0–1 GiB | Disk fuse; 0 = no cap, content never truncated to save context |
digestTimeoutMs | 120000 | ≥ 1000 | Wall clock for one digest run (ms); no card row |
decayIntervalMs | 86400000 | ≥ 1000 | Interval arming the periodic decay pass (ms); no card row |
maxInjectedRules | 50 | 0–1000 | Armed rules injected per session, newest first; 0 = no cap; no card row |
The last three rows are deployment values: not .volatile(), so the card has no row for them and they are given only on the registration line's config:, taking effect on restart.
Public interface
Four methods on one official Typert Remote namespace, lessonLoopRules.
| Method | Input | Reply |
|---|---|---|
stats() | — | { ok: true, …counters, …config summary, rules }; the rule projection carries the derived undeterminable flag and no evidence bodies |
rules(request) | { project? } | { ok: true, rules }; a non-string project reads as absent, i.e. the whole library |
lessons(request) | { project?, limit? } | { ok: true, count, lessons }; a non-finite, ≤ 0 or absent limit means everything |
ruleAction(request) | { id, action, statement? } | { ok: true, id, action }, or { ok: false, error, status } |
actionmust be one of the six whitelisted values;statementis read byarmandeditonly, trimmed, and a blank string counts as "not given".- A refusal's
statusis a retained semantic code, not an HTTP status: 400 for a missing field or an off-whitelist action, 404 for an unknown card, 500 for a change the library refused to persist (carryingrules-not-persisted). - Reads are pulled, not pushed: on panel open, after a save, and after each manual action. There is no polling loop and no client-side wall clock.
Data and privacy
| Aspect | Content |
|---|---|
| Written to disk | The event stream at $DSH_HOME/cache/lesson-loop/events.jsonl (a discardable derived-data location) plus its transient .lock / .tmp siblings, and the rules array in the lesson-loop section of the settings document |
| Network | No call of its own. /lessons-digest sends this session's negative feedback and the project's recent lessons to the current default model through the host llm service, so that content leaves the machine when that model is remote |
| Retention | The event stream keeps details and rule statements in full, with zero truncation; maxLessonsBytes is a disk fuse, not a context cap |
| Evidence convergence | Rule-card evidence is the one exception: an over-long detail is truncated with an ellipsis and only the most recent rows per card are kept, at write time only. signature and the counters are never trimmed |
| Concurrency | Rule-library writes carry cross-process CAS; event-stream appends share one lock with trimming |
| Language | Host-side wording follows the official locale preference; stored rule statements are user data and are never rewritten |
Known boundaries
- Nothing is promoted automatically — a rule that armed itself would turn one noisy report into a permanent instruction.
- Undeterminable rules do not converge: with no clean hit there is no denominator, so only a human retires them, and the injection cap keeps that population out of the context.
- Subagent sessions are skipped — a second injection would count as exposure for a rule they never saw.
- The card is pull-based, so a value cached across a reconnect would be stale by design.
- Evidence bodies never cross the wire; the card sees the signature and the counters.
- No machine translation: rule statements and evidence are user data.
- Optional services remove capabilities, not the plugin: no
commandsmeans no/lessons-digest, nosystemPromptmeans no standing section, nollmmeans the digest refuses, notimerleaves manual review only; withoutsettingsthe entry never activates and the startup diagnostic names it. - A digest run can partially fail: cards that did not reach the library are reported separately, and the receipt never claims "distilled" for a card that is not on disk. A response that contains no parseable JSON array is a hard error, not "zero candidates".
FAQ
| Question | Answer |
|---|---|
| Do rules ever enable themselves? | No. Only arm writes armed; reaching the revive threshold only returns a rejected card to candidate. |
What happens with reportEnabled off? | report() returns { ok: false, reason: "disabled" } immediately and /lessons-digest refuses; the Remote reads and the card keep working. |
The arm button reports rules-not-persisted. | That change never reached the library; the old state still holds, so retry. |
| A rule keeps showing "undeterminable". | suppressed is still zero, so there is no clean hit to divide by: the rule either sat past decayDays or hit demoteThreshold violations without one followed session. With zero violations too the ratio is undefined, not 1.0 — either way the machine will not decide for you. Disable or archive it yourself; one pass clears the flag. |
I hand-edited rules into a non-array. | The rule side degrades to "reads empty, refuses writes" and this process never writes that section; the switches keep working until you repair the field. |
| Another plugin cannot see the bus. | Without this package, or when ctx.provide is unavailable (one explicit error), reporters drop their reports and their own functionality is unaffected. |
Development
pnpm install
pnpm run check # typecheck → lint → client build → host build → types → tests → format
test/splits by the class of defect a stub cannot see: unit tests drive the store and the pure helpers;test/integration/drives a real cordis and loader, so service hand-out, siblinginjectresolution, activation order and bulkhead isolation are measured rather than mocked.- Two artifact gates:
test/build-host.test.tsholds the cross-package imports external as bare specifiers,test/dist-size.test.tsa size budget, so an inlined dependency cannot pass unnoticed.test/publish-manifest.test.tsguards the published shape — entry targets covered byfiles, no source file as an entry point, host packages absent fromdependencies,dsh-*pins exact and uniform, no dates in comments or documents. - Coverage thresholds are 100% across four metrics, so a new branch needs a case that reaches it.
License
MIT. Source at github.com/JayYuen666/dsh-lesson-loop. Issues go to that repository's tracker.
This package contains no third-party source code: host.js and client.js keep every cross-package import external as a bare specifier. Runtime dependencies carry their own licenses; THIRD_PARTY_NOTICES.md records that artifact boundary and how to re-check it.