---
slug: "minhduydev-pi-learning"
source_type: "readme"
source_url: "https://cdn.jsdelivr.net/gh/minhduydev/pi-learning@main/README.md"
repo: "https://github.com/minhduydev/pi-learning"
source_file: "README.md"
branch: "main"
---
# @minhduydev/pi-learning

Append-only, project-local **learning** package for
[Pi](https://github.com/earendil-works/pi-coding-agent): a trust ledger that records
observations and promotes them through an explicit state machine with two separate
gates — one you drive by hand, and one auto-safe mode drives for you.

> **Read this first.** In a **Pi-trusted** project with no configuration of its own,
> auto-safe is **on by default** in Pi-trusted projects: low-risk observations are
> captured, approved, and activated *without a human step*, and activated learnings
> are injected into agent turns. Those records land in the separate `auto-approved` /
> `auto-active` states and are labelled `auto-safe` everywhere they surface, so they
> are never presented as something you reviewed. In an **untrusted** project the
> runtime is fully inert: no capture, mutation, activation, or injection runs.
> Learning records are not readable through `show` / `recall`, even if an
> operator-enabled ledger already exists. The sole write exception is the
> separate phase5 security-quarantine inbox: rejected external signals are
> durably recorded there, never applied to the learning ledger.
> To turn automation off, see [Turning it off](#turning-it-off).

This is a **selective fork** of Matt Devy's `pi-continuous-learning` (MIT). See
[`UPSTREAM.md`](https://github.com/minhduydev/pi-learning/blob/HEAD/UPSTREAM.md) for provenance, kept concepts, intentional
divergence, and sync policy. The upstream runtime was rewritten — this is **not** a
compatibility fork and ships no compatibility shims.

## What's new in 0.5.0

- **V2 context requests** retrieve context for evidence-free launch intents while reserving proof binding for completion.
- **Privacy-safe telemetry** reports bounded timing, counts, result codes, and tagged digests without prompt text, learning content, paths, or raw errors.
- **Correlated delivery and usage receipts** preserve durable learning identity across restart, injection, usage, and task outcome.
- Peer `@minhduydev/pi-core` moved to `^0.3.0`. See `CHANGELOG.md`.

## What it does (and does not) do

- **Two gates, never conflated.** The manual `/learn review -> approve -> activate`
  flow produces `approved` / `active`. Auto-safe produces `auto-approved` /
  `auto-active`. The replay physically cannot put a system actor into a human-gated
  state, and the record fields are separate (`approval`/`activation` vs
  `autoApproval`/`autoActivation`).
- **Auto-safe is bounded.** It only acts on the conservative kinds `pattern` and
  `discovery`, only when every evidence reference meets `minEvidenceTrust`
  (`verified-command` by default), never on content matching the high-risk pattern
  (credentials, permissions, destructive operations), and never on a record that
  conflicts with an existing one.
- **No model, no network, no system prompt.** It does not call a model or provider,
  mutate the system prompt, start timers, spawn processes, or register tools.
  Injection into a turn is an additive, `display: false` context message.
- **Project-local.** Learnings live under `.pi/artifacts/learning/v1/` in the
  current project. There is no global/home store.
- **Append-only, recoverable.** `events.jsonl` is the source of truth; `index.json`
  is a rebuildable projection. Writes are idempotent, locked across processes,
  `fsync`'d, and validated before they are persisted.
- **Fail-closed configuration.** Every configuration read that fails — missing,
  unreadable, malformed, unknown field, unsafe value — disables automation. A typo
  while trying to switch it off can never switch it on.
- **Explicit access capability.** Effective config exposes learning-record access
  as `none` or `read-write`, separately from automation, plus an explicit
  append-only `securityQuarantine` capability. Untrusted projects can never
  acquire learning-record access or automation; only rejected external signals
  may enter the quarantine inbox.

## Turning it off

Either of these, in order of precedence:

```jsonc
// .pi/settings.json — project-owned, wins over everything else
{ "pi-learning": { "enabled": false, "mode": "manual" } }
```

```
/learn disable      # writes .pi/artifacts/learning/v1/config.json
```

`/learn status` always prints the **effective** configuration and where it came
from, so the UI and the runtime cannot disagree.

## Trust model (v1)

```
                      human gate
observation -> candidate -> approved -> active ----> retired
                     |                                 ^
                     |  auto-safe gate                 |
                     +-> auto-approved -> auto-active -+
                                  |            |
                                  +------------+--> /learn approve promotes
                                                    into the human gate
```

- No `candidate -> active` bypass. Human approval and activation are separate,
  explicit actions, and only `actor: "human"` events reach `approved` / `active`.
- The auto-safe gate is a *parallel* path, not a shortcut through the human one. It
  requires `actor: "system"` **and** `mode: "auto-safe"` on the event; either alone
  is rejected as `authorized-actor-required`.
- `/learn approve <id>` on an `auto-approved` / `auto-active` record promotes it:
  the machine gate is cleared, your approval is bound to the exact content digest,
  and the record drops to `approved` until you activate it.
- Retrieval serves both `active` and `auto-active`, and every result carries a
  `trust: "human" | "auto-safe"` label.
- Approval binds an exact SHA-256 **content digest**. Editing a record creates a
  new revision with a new digest and **invalidates** any prior approval.
- Approved/active records are never auto-mutated, decayed, or deleted.
- Retiring records a reason and an optional supersession link.
- Evidence references carry `source`, `locator`, `digest`, `observedAt`, optional
  `session`/`task`/`worktree` ids, and a `trust` classification. No float confidence
  is used as a trust gate.

## Safe analysis pipeline (library, no model calls)

Four focused, dependency-light modules provide the evidence -> review -> retrieval
path used by the manual `/learn` workflow. Nothing here calls a model, provider,
network, tool, mutates the system prompt, or runs a background scheduler (static
and runtime asserted by tests, including `src/index.ts`).

### `safety.ts` — `SafetyScanner` port + conservative default

A self-contained, dependency-free scanner. The package cannot import private
pi-harness safety internals, so it exposes a `SafetyScanner` port with a
`ConservativeSafetyScanner` default a consumer may override.

- **Quarantines (blocking):** hidden / bidi / zero-width / control Unicode and
  prompt-injection override patterns. Content with a blocking finding is never
  forwarded to a payload.
- **Redacts (defense-in-depth):** AWS access keys, GitHub tokens, Slack tokens,
  PEM private-key blocks, bearer tokens, `key=value` credential assignments,
  and absolute POSIX/Windows home paths (`/Users/...`, `/home/...`,
  `C:\Users\...`). Safe project-relative paths are preserved. Redaction
  reports carry only counts and kinds — never raw secret material.
- Source allowlisting remains the **primary** control; redaction is secondary.

### `evidence.ts` — manual evidence -> validated, frozen payload

Turns allowlisted *manual* `EvidenceInput` into validated `EvidenceRef` s
and a deterministic, frozen `ReviewPayload`.

- `observedAt` must be a real ISO-8601 timestamp with a timezone.
- `EvidenceInput` has **no `trust` field**: trust is derived from the declared
  `source` and bound into the evidence digest. The v1 command accepts only human
  evidence; future adapters must authenticate stronger source classes.
- `locator` / `sessionId` / `taskId` / `worktreeId` are bounded and
  normalized: safe opaque ids are kept; path-like, secret-bearing, or otherwise
  unsafe identifiers are sha256-hashed. Hidden Unicode or prompt injection in
  any field is quarantined (rejected), never hashed-through.
- Excerpts are bounded (`maxExcerptChars`), scanned (injection/hidden-Unicode
  rejected), redacted (secrets + home paths), and keep safe project-relative
  paths.
- `ReviewPayload.bytes` is the canonical encoding binding `projectKey`, an
  optional `worktreeKey`, a non-empty bounded `query`, and the evidence
  (at least one ref). `digest = sha256(bytes)`;
  `estimatedTokens = ceil(bytes/4)`; plus an aggregated redaction report. The
  preview bytes are exactly the analyzed bytes.
- `buildReviewPayload` does not trust passed `EvidenceBuildResult` objects:
  it re-derives trust from source, re-normalizes ids, re-scans excerpts,
  recomputes each ref digest and rejects a mismatch (forgery/mutation), and
  freezes a fresh canonical snapshot.
- Hard bounds enforced: `maxEvidenceRefs`, `maxExcerptChars`,
  `maxPayloadChars`, `maxInputTokens`, `MAX_QUERY_CHARS`. No session
  mirror and no automatic capture.

### `review.ts` — strict candidate parser + human-gated `LearningService`

- `parseCandidates` treats analyzer (or manual) output as **untrusted**:
  unknown fields are rejected, candidate count is bounded, `title`/`guidance`/
  `applicability` are length-bounded, every text field is scanned (injection/
  hidden-Unicode rejected, secrets redacted), and each evidence reference must
  **bind to the reviewed payload** by canonical equality (which also locks its
  digest and derived trust). The candidate's `payloadDigest` must equal the
  reviewed payload digest.
- `LearningService.createCandidate` and `revise` do **not** accept an
  arbitrary `RecordContent`. They accept only a parsed candidate bound to an
  exact `ReviewPayload` digest and construct the `RecordContent` internally,
  taking project/worktree scope and evidence from the payload. Safety and
  evidence binding are revalidated at persistence time, so a tampered or unbound
  candidate is rejected.
- Lifecycle: candidate -> **human exact-current-digest approval** -> **separate
  human activation** -> human revision (invalidates approval) / retirement.
  There is **no candidate -> active path**: the domain builders hardcode the
  `human` actor for every transition after proposal. Runtime human presence is
  enforced at the trusted interactive command boundary; lower-level service APIs
  remain internal package implementation. No model or network calls.

### `retrieval.ts` — explicit, bounded, project-scoped retrieval

- **Query-driven only:** an explicit non-empty query is required; there is no
  automatic retrieval or injection.
- Considers only `active` records whose activation enabled retrieval, within a
  strict project/worktree scope (project-wide records match any worktree;
  worktree-scoped records match only their worktree).
- **Unicode-aware lexical relevance** (no embeddings): query terms are segmented
  with `Intl.Segmenter` (word granularity, with a property-escape fallback) and
  scored against title / applicability / guidance / evidence excerpts with fixed
  weights. Vietnamese, CJK, and Korean matching work. Zero-score records are
  excluded.
- Active content — **including evidence excerpts** — is scanned before output
  and unsafe records are dropped.
- **Absolute hard ceilings** that config cannot exceed: 3 items, 400 serialized
  chars/item, 1200 serialized chars aggregate, counted on the actual canonical
  serialization of each item and the items array. Each item returns one bounded
  `text` field plus provenance (id, content digest, score, evidence sources).

### `identity.ts` / `command.ts` / `index.ts` — one `/learn` command

- `identity.ts` walks parent directories (read-only `node:fs`, no spawning) to
  the nearest `.git` root and derives an opaque `sha256:` project key from the
  sanitized remote identity when available (credentials stripped, scheme/port
  dropped), else the hashed canonical root. A separate opaque worktree key is
  derived inside a linked git worktree (`.git` file + `commondir`). Raw paths
  and credential-bearing remote URLs never leave the module. The store,
  evidence payloads, and retrieval all share this one identity, so running
  `/learn` from a subdirectory finds the same state as the project root.
- `command.ts` owns the full command surface — `help`, `status`, `enable`,
  `disable`, `show`, `recall`, `review`, `approve`, `activate`, `retire`,
  `purge` — over a structural context (no SDK import). `status`/`help`/`show`/
  `recall` are non-creating reads; every mutation (`enable`/`disable`/`review`/
  `approve`/`activate`/`retire`/`purge`) requires `hasUI` + a trusted project +
  explicit confirmation. `review` only creates a candidate (never approves or
  activates in the same flow); `approve` previews the exact current
  contentDigest; `activate` is a separate confirmation acting only on approved
  records; `retire` is final; `purge` uses high-friction confirmation (type
  "purge" + confirm).
- `show` and `recall` use a typed learning-record capability and are refused in
  an untrusted checkout. The phase5 signal inbox is a separate append-only
  security quarantine: it may persist a rejected request for audit, but never
  mutates, activates, or injects a learning record.
- Pi 0.81.1 has **no editor API**, so the review flow notifies the **exact
  `payload.bytes` verbatim** plus the digest, then confirms that digest — a
  stronger binding than an editor round-trip (no mutated-bytes state to
  reconcile). Evidence source stays `human`; the user cannot self-attest a
  higher trust level.
- `index.ts` is a tiny SDK adapter: one `registerCommand("learn")`, no
  tools/hooks/model/network/system-prompt/timers/spawn. `notify` works in
  noninteractive contexts (read-only subcommands produce output without UI);
  only `confirm`/`input` require `hasUI`. A top-level error boundary turns
  invalid/quarantined input or a corrupt store into a concise error
  notification rather than an uncaught command crash.

### What this slice does **not** do

- No model, provider, network, tool, system-prompt mutation, or
  background-scheduler calls in any of these modules or `index.ts` (static +
  runtime asserted by tests).
- No embeddings / semantic search; retrieval is lexical only.
- No automatic session or transcript capture; evidence is manual and
  allowlisted.
- No age-based retention schedule. Local records remain until explicit retirement
  or confirmed `/learn purge`. The store may compact bounded event history into a
  schema-v2 `ledger_checkpoint`; compaction preserves the replay projection and
  exact historical event-id membership, and never silently discards a record.
  Unsupported retention config is rejected.

### Ledger integrity and compaction

Ordinary lifecycle events retain the on-disk schema `v1`. A compacted generation
uses an explicitly versioned schema `v2` checkpoint, binds to the prior chain
tail, and carries the state snapshot plus the event ids already committed. The
store verifies both the event log and its sidecar chain before compaction. A
durable intent file makes interrupted compaction and purge recover forward under
the append lock; lock-free snapshots fail closed while files are between
generations. Event lines, records, chain entries, and recovery markers have hard
byte/count limits. Threshold-triggered compaction is best-effort maintenance:
once an event append is fsynced, a checkpoint-size or maintenance failure cannot
retroactively report that append as rejected. `LearningStore.compact()` remains
the explicit API when a caller needs the compaction result or error.

## Config defaults

Defaults below apply to a **Pi-trusted** project with no configuration of its own.
An untrusted project is forced to `enabled: false, mode: "manual"` with every
automation switch off and learning-record access `none`, regardless of what its
`.pi/settings.json` or persisted config says. The separate phase5
`securityQuarantine` capability remains append-only so rejected external input
can be retained as audit evidence; it is not persisted learning state.

| Field | Default | Note |
| --- | --- | --- |
| `enabled` | `true` | automation is on for trusted projects |
| `mode` | `auto-safe` | `manual` and `assist` are also accepted; `automatic` is rejected |
| `scope` | `project` | `global` is rejected |
| `capture` | `manual` | `automatic` is rejected. Auto-safe capture runs through `mode`, not this field |
| `autoActivate` / `autoInject` | `true` | only legal while `mode` is `auto-safe` (`autoInject` also in `assist`); forced `false` otherwise |
| `autoActivation.allowedKinds` | `["pattern", "discovery"]` | the only kinds auto-safe may act on |
| `autoActivation.minEvidenceTrust` | `verified-command` | every evidence ref must meet this |
| `retrieval` | `explicit`, `3` / `400` / `1200` | maxItems / maxItemChars / maxTotalChars |
| `allowGlobalScope` / `autonomousEdits` | `false` | setting either to `true` fails closed |
| `approval.requireHuman` / `approval.separateActivation` | `true` | invariant of the `approved`/`active` states; `false` fails closed |

Config is parsed strictly and **fails closed** on unknown schema versions, unknown
fields, or unsupported unsafe values — and a failed parse disables automation
rather than falling back to these defaults.

### Where configuration comes from

Highest precedence first:

1. `.pi/settings.json` → `pi-learning` (project-owned override)
2. `.pi/artifacts/learning/v1/config.json` (written by `/learn enable|disable`)
3. The package defaults above — trusted projects only

One resolver (`resolveLearningConfig`) serves both the runtime and the `/learn`
command, and `/learn status` names the source it used.

## Requirements

- Pi `@earendil-works/pi-coding-agent` 0.81.x (optional peer; tested against
  0.81.1 and provided by Pi at load time). The package imports only types from it.
- Node >= 22.19.0.

## Development

```bash
npm install
npm run typecheck    # tsc --noEmit
npm test             # vitest run
npm run build        # tsc -p tsconfig.build.json -> dist/
npm pack --dry-run   # inspect the published file list
```

## License

MIT. The upstream MIT license (Copyright (c) 2026 Matt Devy) is preserved verbatim in
[`LICENSE`](https://github.com/minhduydev/pi-learning/tree/HEAD/LICENSE).
