diff --git a/.changeset/agent-skills.md b/.changeset/agent-skills.md new file mode 100644 index 0000000..07f1d03 --- /dev/null +++ b/.changeset/agent-skills.md @@ -0,0 +1,7 @@ +--- +"pi-lossless": patch +"opencode-lossless": patch +"lossless-core": patch +--- + +Ship a versioned Intent skill with each package (`skills//SKILL.md` in the published tarball): install, command and recall vocabulary, config essentials, and common mistakes, stamped with the package version on release. Adds `skills` to each package's published files and validates skills on publish and in CI. diff --git a/.github/workflows/check-skills.yml b/.github/workflows/check-skills.yml new file mode 100644 index 0000000..8434d91 --- /dev/null +++ b/.github/workflows/check-skills.yml @@ -0,0 +1,83 @@ +name: Check Skills + +on: + pull_request: + paths: + - "packages/*/skills/**" + release: + types: [published] + workflow_dispatch: {} + +jobs: + validate: + name: Validate intent skills + if: github.event_name == 'pull_request' + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + persist-credentials: false + + - name: Setup + uses: ./.github/actions/setup + + - name: Validate skills + run: bun run intent:validate -- --github-summary + + review: + name: Check intent skill coverage + if: github.event_name != 'pull_request' + runs-on: ubuntu-latest + permissions: + contents: write + pull-requests: write + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + fetch-depth: 0 + + - name: Setup + uses: ./.github/actions/setup + + - name: Check skills + id: stale + run: | + bun run intent:stale -- --github-review + + - name: Open or update review PR + if: steps.stale.outputs.has_review == 'true' + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + RELEASE_TAG: ${{ github.event.release.tag_name }} + DEFAULT_BRANCH: ${{ github.event.repository.default_branch }} + run: | + VERSION="${RELEASE_TAG:-manual}" + BRANCH="skills/review-${VERSION}" + BASE_BRANCH="$DEFAULT_BRANCH" + + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + + git fetch origin "$BRANCH" || true + if git show-ref --verify --quiet "refs/remotes/origin/$BRANCH"; then + git checkout -B "$BRANCH" "origin/$BRANCH" + else + git checkout -b "$BRANCH" + git commit --allow-empty -m "chore: review intent skills for ${VERSION}" + git push origin "$BRANCH" + fi + + PR_URL="$(gh pr list --head "$BRANCH" --json url --jq '.[0].url')" + if [ -n "$PR_URL" ]; then + gh pr edit "$PR_URL" --body-file pr-body.md + else + gh pr create \ + --title "Review intent skills (${VERSION})" \ + --body-file pr-body.md \ + --head "$BRANCH" \ + --base "$BASE_BRANCH" + fi diff --git a/bun.lock b/bun.lock index 4a28f15..cf85716 100644 --- a/bun.lock +++ b/bun.lock @@ -6,6 +6,7 @@ "name": "lossless-workspace", "devDependencies": { "@changesets/cli": "^3.0.3", + "@tanstack/intent": "0.5.6", "@types/node": "^26.6.5", "@vitest/coverage-v8": "catalog:", "typescript": "^7.0.2", @@ -22,14 +23,14 @@ }, "packages/core": { "name": "lossless-core", - "version": "0.1.0", + "version": "0.1.1", "dependencies": { "typebox": ">=1.4.3 <2", }, }, "packages/opencode": { "name": "opencode-lossless", - "version": "0.1.0", + "version": "0.1.1", "devDependencies": { "@opencode/ai": "2.1.4", "@opencode/plugin": "2.1.4", @@ -38,7 +39,7 @@ }, "packages/pi": { "name": "pi-lossless", - "version": "0.1.0", + "version": "0.1.1", "devDependencies": { "@earendil-works/pi-agent-core": "1.4.0", "@earendil-works/pi-ai": "1.4.0", @@ -888,6 +889,8 @@ "@takumi-rs/wasm": ["@takumi-rs/wasm@2.17.1", "", { "dependencies": { "@takumi-rs/helpers": "2.17.1" }, "peerDependencies": { "csstype": "*" }, "optionalPeers": ["csstype"] }, "sha512-f9gWUUO4afRBhvSRJVpRaqu4WthGYODtareygjgJJjzp6SCJnHLhFXx7AjuHh+kMjNfQyzCjCqpMeGXzIMJLjg=="], + "@tanstack/intent": ["@tanstack/intent@0.5.6", "", { "bin": { "intent": "dist/cli.mjs" } }, "sha512-9Ta/GMGPcqBIqlbrqeYyqBVKEP7XyAXy6nn90bPiE2oHB6KPgjEweUbHNXLQ6Pim01VtMMNf0dmUFMGKKHXmlw=="], + "@testing-library/dom": ["@testing-library/dom@10.4.2", "", { "dependencies": { "@babel/code-frame": "^7.10.4", "@babel/runtime": "^7.12.5", "@types/aria-query": "^5.0.1", "aria-query": "5.3.0", "dom-accessibility-api": "^0.5.9", "lz-string": "^1.5.0", "picocolors": "1.1.1", "pretty-format": "^27.0.2" } }, "sha512-yzr2S9HyAIdhz2/6qHgbs665Q7PKVcDF05vsOlHPxG1mo36gKVesdYVeDLnXgfjJ03CrKRk08knc6+E/9m8v2Q=="], "@testing-library/user-event": ["@testing-library/user-event@14.6.7", "", { "peerDependencies": { "@testing-library/dom": ">=7.21.4" } }, "sha512-MPCpX8bxe8zS+JmmTwLp8jd0dy1rAm60Te/SL8JrQM3qvQJcBOs1d7IefJMyZzqM3EWBrDn/LWDt1BCGu4ASfg=="], diff --git a/package.json b/package.json index 5732ea8..dc07798 100644 --- a/package.json +++ b/package.json @@ -11,6 +11,8 @@ "check": "vp check", "format": "vp fmt", "format:check": "vp fmt --check", + "intent:stale": "intent stale", + "intent:validate": "intent validate", "lint:ci": "vp lint --quiet", "test": "vp test", "test:coverage": "vp test --coverage --coverage.reporter=lcov", @@ -20,13 +22,14 @@ "opencode:compact": "bun scripts/opencode-compact.ts", "pack": "vp run --filter ./packages/core prepack && vp run --filter ./packages/pi prepack && vp run --filter ./packages/opencode prepack", "changeset": "changeset", - "version": "changeset version && vp fmt packages/*/CHANGELOG.md", + "version": "changeset version && bun scripts/sync-skill-versions.ts && vp fmt packages/*/CHANGELOG.md", "release": "changeset publish", "prepare": "vp config || true", "eval": "bun scripts/eval-long-context.ts" }, "devDependencies": { "@changesets/cli": "^3.0.3", + "@tanstack/intent": "0.5.6", "@types/node": "^26.6.5", "@vitest/coverage-v8": "catalog:", "typescript": "^7.0.2", diff --git a/packages/core/package.json b/packages/core/package.json index af8cc0c..c836562 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -8,7 +8,8 @@ "lcm", "llm", "memory", - "retrieval" + "retrieval", + "tanstack-intent" ], "homepage": "https://github.com/stainless-code/lossless#readme", "bugs": "https://github.com/stainless-code/lossless/issues", @@ -21,7 +22,8 @@ }, "files": [ "dist", - "CHANGELOG.md" + "CHANGELOG.md", + "skills" ], "type": "module", "main": "./dist/index.mjs", @@ -35,7 +37,7 @@ "scripts": { "pack:watch": "vp pack --watch", "prepack": "vp pack", - "prepublishOnly": "vp check && vp test" + "prepublishOnly": "vp check && vp test && intent validate" }, "dependencies": { "typebox": ">=1.4.3 <2" diff --git a/packages/core/skills/lossless-core/SKILL.md b/packages/core/skills/lossless-core/SKILL.md new file mode 100644 index 0000000..9b63d1c --- /dev/null +++ b/packages/core/skills/lossless-core/SKILL.md @@ -0,0 +1,91 @@ +--- +name: lossless-core +description: "Use when wiring a host to lossless-core or working on the engine itself: implement ModelHost or Reader, run a compaction, retrieval, or heal pass, or read the store directly with LcmStore." +license: MIT +metadata: + type: core + library: "lossless-core" + library_version: "0.1.1" +sources: + - stainless-code/lossless:packages/core/src/host.ts + - stainless-code/lossless:packages/core/src/index.ts + - stainless-code/lossless:packages/core/README.md +--- + +# Lossless core + +Use it when implementing a host adapter or touching the engine. Do not use it +for adapter commands: Pi subcommands live in the pi-lossless skill, +`lossless-*` commands in the opencode-lossless skill. + +The engine behind `pi-lossless` and `opencode-lossless`: Lossless Context +Management for a long-running agent. Every message is stored verbatim, +aged context is replaced with searchable pointers, and the original text +stays readable by id, entry, or search. + +## Minimal wiring + +```ts +import { LcmStore, runCompaction, runHeal, runRetrieval } from "lossless-core"; +import type { ModelHost, Reader } from "lossless-core"; +``` + +Call shapes (full signatures in `src/index.ts`, port contracts in `src/host.ts`): + +```ts +const store = new LcmStore(dbPath); +await runCompaction(store, input, llm); // input: CompactionInput (span, budgets) +await runRetrieval(store, request, reader); // request: RetrievalRequest (scope, query) +await runHeal(store, { dryRun: true }, llm); // HealInput: maxNodes, maxCalls, dryRun plan mode +``` + +The engine has no agent SDK dependency. A host supplies two ports and the +engine does the rest. Pi implements both; the OpenCode v2 adapter implements +`ModelHost` and runs retrieval in a host child session instead of `Reader`. + +## What it does + +- **Ingest.** Every message lands in SQLite with its entry id, role, + timestamp, and any file handle the host attached. +- **Compaction.** Past a swap ratio, a pass summarizes spans into leaf and + condensed nodes and leaves a pointer in place. Nothing is deleted. +- **Retrieval.** A reader answers by calling `lcm_grep`, `lcm_describe`, + and `lcm_expand`, so the answer cites what was read. +- **Repair.** `runHeal` re-derives a truncated summary and its ancestors + from the provenance rows they still point at. +- **Redaction.** Secret-shaped text is masked on the way to a model, never + at the door, so an export or read still returns what was stored. + +## The two ports + +`src/host.ts` documents both. `ModelHost` is what the engine calls a model +through: `session()`, `find()`, `complete()`, plus where notices go. +`Reader` is one retrieval conversation: `begin()` returns a `Reading` +whose `next()` appends results and returns the model reply. + +| Type | Question it answers | +| ----------- | ----------------------------------------------------- | +| `ModelHost` | Which model, and what did it answer | +| `Reader` | What did the reader ask for, and what did it conclude | + +## Boundary rule + +No module under `src/` imports an agent SDK, a bare specifier other than +`node:*` and `typebox`, or a file outside the engine. The boundary test +fails any violation. Import the package root only. + +## Common mistakes + +- **Importing engine source deep paths** (`lossless-core/dist/...` or + relative `src/...` files) instead of the package root — the boundary test + fails deep imports; `import { LcmStore } from "lossless-core"` is the only entry. +- **Expecting a host SDK in the bundle** - the host always supplies + `ModelHost` (and `Reader`, unless the host runs retrieval itself, as the OpenCode adapter does); implement the port, never `npm install` an SDK. +- **Assuming compaction deletes** - pointers replace text in context, + the verbatim rows stay queryable via the recall views. +- **Redacting at write time** - masking happens on the model path only; a stored row is never rewritten. + +## API surface + +- `LcmStore`, `runCompaction`, `runRetrieval`, `runHeal` — exported from `src/index.ts`; pass and heal options live next to their runners. +- `ModelHost`, `Reader`, `Reading` (types, `src/host.ts`) — the two port contracts; implement these, never a host SDK. diff --git a/packages/core/test/package-boundary.test.ts b/packages/core/test/package-boundary.test.ts index 3171204..db73764 100644 --- a/packages/core/test/package-boundary.test.ts +++ b/packages/core/test/package-boundary.test.ts @@ -72,14 +72,10 @@ test("package: the engine depends on its schema library and nothing else", () => test("package: the engine is publishable, and ships dist plus the docs it owns", () => { assert.equal(manifest.name, "lossless-core"); assert.equal(manifest.private, undefined); - // npm always includes README and LICENSE from the package directory, so the - // file list names only what it has to be told about. changesets writes the - // changelog in place, as it does for the adapter. - assert.deepEqual(manifest.files, ["dist", "CHANGELOG.md"]); + assert.deepEqual(manifest.files, ["dist", "CHANGELOG.md", "skills"]); for (const name of ["README.md", "LICENSE", "CHANGELOG.md"]) { assert.ok(existsSync(join(PACKAGE_DIR, name)), `${name} is not in the package`); } - // The root keeps the repo's licence; the package ships the same bytes. assert.deepEqual( readFileSync(join(PACKAGE_DIR, "LICENSE")), readFileSync(join(REPO_ROOT, "LICENSE")), @@ -88,9 +84,6 @@ test("package: the engine is publishable, and ships dist plus the docs it owns", const SHIPPED_DOCS = ["README.md", "CHANGELOG.md"]; -/** The links in a shipped doc that resolve outside the package or to a file it - * does not carry. An npm reader has the tarball and nothing around it, so both - * fail for them. */ function deadLinks(source: string): string[] { const dead: string[] = []; for (const match of source.matchAll(/\]\(([^)\s]+)\)/g)) { @@ -105,8 +98,6 @@ function deadLinks(source: string): string[] { } test("package: a shipped doc links only to what its tarball carries", () => { - // A planted violation, so a walk that finds nothing is known to work rather - // than known to be silent, and an inside link it has to leave alone. assert.deepEqual(deadLinks("[the roadmap](../../docs/roadmap.md)"), ["../../docs/roadmap.md"]); assert.deepEqual(deadLinks("[the changelog](./CHANGELOG.md)"), []); const problems = SHIPPED_DOCS.flatMap((name) => @@ -138,8 +129,6 @@ test("package: every name the engine's README promises is exported", () => { const values = imports .filter(([, kind]) => kind === undefined) .flatMap(([, , names]) => namedIn(names!)); - // A type import is erased at runtime, so it is checked against the source - // that declares it; `bun run typecheck` reads the import itself. const types = imports .filter(([, kind]) => kind !== undefined) .flatMap(([, , names]) => namedIn(names!)); diff --git a/packages/opencode/package.json b/packages/opencode/package.json index cdb671d..13564ee 100644 --- a/packages/opencode/package.json +++ b/packages/opencode/package.json @@ -7,7 +7,8 @@ "context", "lcm", "memory", - "opencode" + "opencode", + "tanstack-intent" ], "homepage": "https://github.com/stainless-code/lossless#readme", "bugs": { @@ -22,7 +23,8 @@ }, "files": [ "dist", - "CHANGELOG.md" + "CHANGELOG.md", + "skills" ], "type": "module", "main": "./dist/index.mjs", @@ -35,7 +37,7 @@ "scripts": { "pack:watch": "vp pack --watch", "prepack": "vp pack", - "prepublishOnly": "vp check && vp test" + "prepublishOnly": "vp check && vp test && intent validate" }, "devDependencies": { "@opencode/ai": "2.1.4", diff --git a/packages/opencode/skills/opencode-lossless/SKILL.md b/packages/opencode/skills/opencode-lossless/SKILL.md new file mode 100644 index 0000000..42cf3d9 --- /dev/null +++ b/packages/opencode/skills/opencode-lossless/SKILL.md @@ -0,0 +1,77 @@ +--- +name: opencode-lossless +description: "Use when an OpenCode v2 session has opencode-lossless installed: read compacted history with lcm_grep, lcm_describe, lcm_expand, or lcm_expand_query; run /lossless-status, /lossless-report, /lossless-pass, /lossless-heal, /lossless-backup, /lossless-export, /lossless-import, /lossless-diagnose, /lossless-migrate, or /lossless-gc; or tune the swap point." +license: MIT +metadata: + type: composition + library: "opencode-lossless" + library_version: "0.1.1" +requires: + - lossless +sources: + - stainless-code/lossless:packages/opencode/src +--- + +# opencode-lossless + +Passive mirroring starts with zero config: every prompt, assistant reply, and tool exchange lands in a per-session SQLite sidecar. Past the swap point the DAG projection replaces aged history, and on host compaction the adapter hands over the frontier as the checkpoint instead of a summary. + +## Install + +```sh +opencode plugin add opencode-lossless +``` + +Equivalent `opencode.json` entry: + +```json +{ "plugins": [{ "package": "opencode-lossless", "options": { "sidecarDir": "./.lossless" } }] } +``` + +## Commands + +- `/lossless-status` — message and compaction summary counts per live sidecar. +- `/lossless-report` — this session's metrics: projection, checkpoint, pass, recall, reader. +- `/lossless-pass` — run one DAG pass over the aged span now, without waiting for the swap. +- `/lossless-heal` — dry-run repair of tainted summaries; `--apply` spends model calls. +- `/lossless-backup` — copy this session's sidecar beside it. +- `/lossless-export` — write the verbatim transcript and summaries as JSONL (mode 0600). +- `/lossless-import` — restore rows from an export. +- `/lossless-diagnose` — redacted, pasteable diagnostics block; `--save` writes it at mode 0600. +- `/lossless-migrate` — upgrade older-schema sidecars in place (dry-run by default). +- `/lossless-gc` — list old sidecars, or export and delete them (never the current session). + +## Recall tools + +- `lcm_grep` — full-text search over this session; `scope` reaches past sessions. +- `lcm_describe` — inspect one summary (kind, costs, children) without expanding it. +- `lcm_expand` — read the verbatim originals a summary covers. +- `lcm_expand_query` — a bounded child session reads and answers, so originals never enter the parent context. + +## Config essentials + +```json +{ "enabled": true, "sidecarDir": "./.lossless", "assembly": true } +``` + +- `sidecarDir` holds one SQLite file per session (0700 dir, 0600 files; delete one to forget that session). +- `assembly: true` (default) owns the context boundary: projection swaps aged history and compaction stores the DAG frontier. +- Every other key (swap thresholds, summarizer chain and budgets, heal window, reader timeout) lives on the config reference with its default: https://stainless-code.com/lossless/reference/opencode/config + +## Verify + +From a checkout of this repo (these scripts only exist there, not in a consumer project): + +```sh +bun run opencode:check # read-only: host version, config, bundle hash, sidecar counts +bun run opencode:smoke # one real model call, asserts the verbatim mirror +bun run opencode:compact # compaction gate, makes real model calls +``` + +## Common mistakes + +- **`assembly: false` drops `/lossless-pass` and `lcm_expand_query`** — keep the default `true` unless the host must own compaction; with `false`, compaction falls back to the host. +- **`scope` equivalence** — `scope: "session"` (default) reads this session only; pass `scope: "sessions"` or `"all_sessions"` to reach past sessions. +- **Reader is session-scoped** — `lcm_expand_query` returns findings only through its child session, never the raw transcript; ask a question it can answer from the sources, not for a dump. +- **Forgetting is file deletion** — remove that session's sidecar file; there is no delete tool. +- **Expecting host compaction to summarize** — with assembly on, the checkpoint is the DAG frontier, not a prose summary; read it with `lcm_describe`, not by asking the host. diff --git a/packages/opencode/test/publish-surface.test.ts b/packages/opencode/test/publish-surface.test.ts index ae6f9a9..1c83d04 100644 --- a/packages/opencode/test/publish-surface.test.ts +++ b/packages/opencode/test/publish-surface.test.ts @@ -28,7 +28,7 @@ test("publish surface: the manifest names the built entry and its own docs", () assert.equal(manifest.exports?.["."]?.default, manifest.main); assert.equal(manifest.exports?.["."]?.types, "./dist/index.d.mts"); assert.equal(manifest.repository?.directory, "packages/opencode"); - assert.deepEqual((manifest.files ?? []).toSorted(), ["CHANGELOG.md", "dist"]); + assert.deepEqual((manifest.files ?? []).toSorted(), ["CHANGELOG.md", "dist", "skills"]); assert.deepEqual(manifest.dependencies ?? {}, {}); assert.deepEqual(manifest.peerDependencies ?? {}, {}); for (const name of ["README.md", "LICENSE", "CHANGELOG.md"]) { @@ -40,7 +40,6 @@ test("publish surface: the manifest names the built entry and its own docs", () ); }); -/** The specifiers one file names, in the three forms this repo writes them. */ const SPECIFIER_PATTERNS = [ /(?:^|\n)[}\s]*(?:import\s+(?:[^\n]*?\b)?from\s+|from\s+)["']([^"']+)["']/g, /(?:^|\n)[}\s]*import\s+["']([^"']+)["']/g, diff --git a/packages/pi/package.json b/packages/pi/package.json index 5cc38fe..39c9071 100644 --- a/packages/pi/package.json +++ b/packages/pi/package.json @@ -8,7 +8,8 @@ "lcm", "memory", "pi", - "pi-package" + "pi-package", + "tanstack-intent" ], "homepage": "https://github.com/stainless-code/lossless#readme", "bugs": "https://github.com/stainless-code/lossless/issues", @@ -21,7 +22,8 @@ }, "files": [ "dist", - "CHANGELOG.md" + "CHANGELOG.md", + "skills" ], "type": "module", "main": "./dist/index.mjs", @@ -34,7 +36,7 @@ "scripts": { "pack:watch": "vp pack --watch", "prepack": "vp pack", - "prepublishOnly": "vp check && vp test" + "prepublishOnly": "vp check && vp test && intent validate" }, "devDependencies": { "@earendil-works/pi-agent-core": "1.4.0", diff --git a/packages/pi/skills/pi-lossless/SKILL.md b/packages/pi/skills/pi-lossless/SKILL.md new file mode 100644 index 0000000..9cc2c1d --- /dev/null +++ b/packages/pi/skills/pi-lossless/SKILL.md @@ -0,0 +1,83 @@ +--- +name: pi-lossless +description: "Use when a Pi session has pi-lossless installed: search compacted history with lcm_grep, lcm_describe, or lcm_expand_query; run /lcm status, settings, backup, export, report, doctor, gc, migrate, heal, or diagnose; or tune compaction thresholds." +license: MIT +metadata: + type: composition + library: "pi-lossless" + library_version: "0.1.1" +requires: [] +sources: + - stainless-code/lossless:packages/pi/src +--- + +# pi-lossless + +Aged conversation becomes DAG summaries the model can navigate; the full +verbatim history stays searchable and recoverable. Session file is never +modified; the store (`~/.pi/agent/lcm/`, plaintext SQLite, chmod 0600) is a +derived index that can be deleted and rebuilt. + +## Install + +```bash +pi install npm:pi-lossless +``` + +Passive logging starts immediately with zero config; swaps begin past the +soft threshold (default 70% of the model window). + +## /lcm subcommands + +- `/lcm` — same as `/lcm status` (default subcommand). +- `/lcm status` — current projection state: messages, pin, occupancy, thresholds. +- `/lcm settings` — the LCM Cockpit: live gauge, cycle-edit every threshold (TUI only). +- `/lcm backup` — copy the live store to a timestamped file. +- `/lcm export` — write the full store as JSONL (verbatim bytes, secrets included). +- `/lcm report` — metrics summary for the current session. +- `/lcm doctor` — diagnostics: store checks, thresholds, summarizer chain, footprint; `--repair` rebuilds the index and drops unreachable rows. +- `/lcm gc` — dry-run list of retired stores; `--apply` exports then DELETEs them (destructive; export lands beside the stores). +- `/lcm migrate` — dry-run list of older-schema stores; `--apply` upgrades them in place for cross-session search. +- `/lcm heal` — dry-run repair of tainted summaries; `--apply` spends model calls, `--max-nodes ` bounds the run. +- `/lcm diagnose` — one pasteable block for a bug report; `--save ` writes it to a file (mode 0600, capped at 8 KB, no message bodies). + +## Recall tools + +- `lcm_grep` — search ALL history, including compacted-out content. `query` is full-text over the token index (words, quoted phrases, `AND`/`OR`, prefixes; first 100,000 chars per message). `pattern` is a case-insensitive regex over full text (up to 200,000 chars per message, 1s cap per call) — use it for pointers, calls, punctuation, or spans the index cannot hold. Hits page 20 at a time via `offset`; `query` reports a total, `pattern` reports whether more exist. `summary_id` scopes to one node; `scope: "sessions"` / `"all_sessions"` reaches past sessions. +- `lcm_describe` — inspect a summary: what reading its originals would cost and which files it covers. +- `lcm_expand_query` — bounded reader over stored originals; returns findings without that text entering the main conversation. Default budget 10,000 tokens over at most 6 reader turns; `max_tokens` changes the per-call budget. `summary_id` reads one node, `entry_id` one message. Cross-session: `lcm_expand_query({ session: "", entry_id: "" })` on a grep hit; findings are tagged as past-session history. Unknown ids are refused before any model call. + +Reach for recall tools when the answer is in history; reach for subcommands when acting on the store (backup, repair, migrate, gc, diagnose). + +```ts +lcm_grep({ query: "migration plan", scope: "sessions", limit: 20 }); +``` + +## Config (`~/.pi/agent/lcm.json`, strict JSON) + +```json +{ + "smartZone": 110000, + "zones": { + "openrouter/z-ai/glm-5.3-flash": { "swapAtTokens": 110000, "recutAtTokens": 130000 } + }, + "swapAtRatio": 0.7, + "recutAtRatio": 0.85, + "summarizer": ["openrouter/z-ai/glm-5.3-flash", "anthropic/claude-haiku-4-5", "auto"] +} +``` + +- `smartZone`: easy mode, one number; recut derived (x1.18). +- `zones`: per-model overrides keyed `"provider/id"` (thresholds only); explicit tokens beat ratios. +- `swapAtRatio` / `recutAtRatio`: ratio mode, the default when no token thresholds are set. +- `summarizer`: one model or an ordered fallback chain. No comma form (ids may contain commas). `auto` is the session model only, never "something cheap"; unset behaves as `auto`. Tried in order on missing/failing/empty output; failures demote to the back for the pass. `/lcm doctor` prints the chain that actually runs. +- Corrupt file is left untouched with defaults applied; out-of-range numbers and wrong types are ignored. Overwrites back up to `lcm.json.bak`. +- Full key list (largeFileChars, per-call and per-pass budgets, heal budgets, redaction flag) with defaults: https://stainless-code.com/lossless/reference/pi/config + +## Common mistakes + +- **Expecting the settings UI outside the TUI** — `/lcm settings` is an interactive cockpit; in print/RPC mode there is no live gauge, use `/lcm status` and `/lcm doctor`. +- **`query` vs `pattern` confusion** — `query` cannot see punctuation, ids, or spans; anything a pointer, call signature, or exact span needs must be `pattern`. +- **Assuming cross-session search is unbounded** — stops at 20 stores, 40 hits, or 1.5s, whichever comes first; older-generation stores are counted, not read, until `/lcm migrate`. +- **Treating a gc transcript as searchable** — a store `/lcm gc` deletes is no longer readable by any scope; the export beside the stores keeps bytes only. +- **Disabling redaction and expecting disk changes** — `redactSecrets: false` (config flag, default on) affects outbound reads only; stored rows are never rewritten. diff --git a/packages/pi/test/publish-surface.test.ts b/packages/pi/test/publish-surface.test.ts index 5f213ea..ccb863d 100644 --- a/packages/pi/test/publish-surface.test.ts +++ b/packages/pi/test/publish-surface.test.ts @@ -35,7 +35,7 @@ test("publish surface: the manifest keeps every field Pi discovers the extension assert.equal(manifest.exports?.["."]?.types, manifest.main?.replace(/\.mjs$/, ".d.mts")); assert.ok(existsSync(join(PACKAGE_DIR, "src", "index.ts"))); assert.equal(manifest.repository?.directory, "packages/pi"); - assert.deepEqual((manifest.files ?? []).toSorted(), ["CHANGELOG.md", "dist"]); + assert.deepEqual((manifest.files ?? []).toSorted(), ["CHANGELOG.md", "dist", "skills"]); for (const name of ["README.md", "LICENSE", "CHANGELOG.md"]) { assert.ok(existsSync(join(PACKAGE_DIR, name)), `${name} is not in the package`); } @@ -69,8 +69,6 @@ function deadLinks(source: string): string[] { test("publish surface: a shipped doc links only to what its tarball carries", () => { assert.deepEqual(deadLinks("[the roadmap](../../docs/roadmap.md)"), ["../../docs/roadmap.md"]); - // The comparison lives in the docs site, so a shipped doc naming it by a - // relative path is dead by construction, and the README links the site page. assert.deepEqual( deadLinks("[the comparison](../../apps/docs/content/reference/pi/comparison.mdx)"), ["../../apps/docs/content/reference/pi/comparison.mdx"], @@ -82,9 +80,6 @@ test("publish surface: a shipped doc links only to what its tarball carries", () assert.ok(adapterFiles().length >= 15, `walked ${adapterFiles().length} adapter files`); }); -/** The specifiers one file names, in the three forms this repo writes them. A - * local copy rather than an import from the engine's boundary test: the adapter - * reaches the engine through its entry, its tests included. */ const SPECIFIER_PATTERNS = [ /(?:^|\n)[}\s]*(?:import\s+(?:[^\n]*?\b)?from\s+|from\s+)["']([^"']+)["']/g, /(?:^|\n)[}\s]*import\s+["']([^"']+)["']/g, @@ -115,8 +110,6 @@ function typescriptFiles(dir: string): string[] { return files; } -/** The adapter's shipped source and its tests. Its test support is shared with - * the engine on purpose (one HOME guard); the engine's source is not. */ function adapterFiles(): string[] { return [join(PACKAGE_DIR, "src"), join(PACKAGE_DIR, "test")].flatMap((dir) => typescriptFiles(dir), @@ -142,9 +135,6 @@ test("publish surface: the adapter reads the engine through its entry and no dee }); test("publish surface: every package the adapter imports has a field that declares it", () => { - // The pack inlines what it can and leaves `peerDependencies` to the host, so an - // undeclared host package ships Pi's own code inside dist/index.mjs, and a - // runtime `dependencies` entry would leave the engine out of the tarball. const shipped = typescriptFiles(join(PACKAGE_DIR, "src")); const imported = new Set(shipped.flatMap((file) => specifiers(readFileSync(file, "utf8")))); const atRuntime = new Set(shipped.flatMap((file) => valueSpecifiers(readFileSync(file, "utf8")))); @@ -154,8 +144,6 @@ test("publish surface: every package the adapter imports has a field that declar for (const name of [...atRuntime].filter( (one) => !one.startsWith("node:") && !one.startsWith("."), )) { - // `lossless-core` is the one package the adapter inlines on purpose, and it - // is the workspace entry below this one. if (name === "lossless-core") continue; assert.ok(peers.includes(name), `${name} is imported at runtime, so it must be a peer`); } diff --git a/scripts/sync-skill-versions.ts b/scripts/sync-skill-versions.ts new file mode 100644 index 0000000..d93657b --- /dev/null +++ b/scripts/sync-skill-versions.ts @@ -0,0 +1,36 @@ +import { existsSync, readFileSync, readdirSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; + +const FIELD = /^( *library_version:\s*")([^"]*)(")/m; + +function* walkSkillFiles(dir: string): Generator { + for (const entry of readdirSync(dir, { withFileTypes: true })) { + if (!entry.isDirectory()) continue; + const nested = join(dir, entry.name); + const skill = join(nested, "SKILL.md"); + if (existsSync(skill)) yield skill; + yield* walkSkillFiles(nested); + } +} + +let touched = 0; +for (const pkg of readdirSync("packages", { withFileTypes: true })) { + if (!pkg.isDirectory()) continue; + const pkgDir = join("packages", pkg.name); + const manifestPath = join(pkgDir, "package.json"); + if (!existsSync(manifestPath)) continue; + const { version } = JSON.parse(readFileSync(manifestPath, "utf8")); + if (typeof version !== "string") continue; + const skillsDir = join(pkgDir, "skills"); + if (!existsSync(skillsDir)) continue; + for (const file of walkSkillFiles(skillsDir)) { + const before = readFileSync(file, "utf8"); + const after = before.replace(FIELD, `$1${version}$3`); + if (after !== before) { + writeFileSync(file, after); + touched++; + } + } + console.log(`sync-skill-versions: ${pkgDir} → ${version}`); +} +console.log(`sync-skill-versions: ${touched} skill(s) touched`);