Compare commits

..
Author SHA1 Message Date
james.maes 06dec906bf Add .circleci/config.yml 2026-04-16 15:14:36 -05:00
71 changed files with 560 additions and 12945 deletions

No files matched your search

+31
View File
@@ -0,0 +1,31 @@
# Use the latest 2.1 version of CircleCI pipeline process engine.
# See: https://circleci.com/docs/reference/configuration-reference
version: 2.1
# Define a job to be invoked later in a workflow.
# See: https://circleci.com/docs/guides/orchestrate/jobs-steps/#jobs-overview & https://circleci.com/docs/reference/configuration-reference/#jobs
jobs:
say-hello:
# Specify the execution environment. You can specify an image from Docker Hub or use one of our convenience images from CircleCI's Developer Hub.
# See: https://circleci.com/docs/guides/execution-managed/executor-intro/ & https://circleci.com/docs/reference/configuration-reference/#executor-job
docker:
# Specify the version you desire here
# See: https://circleci.com/developer/images/image/cimg/base
- image: cimg/base:current
# Add steps to the job
# See: https://circleci.com/docs/guides/orchestrate/jobs-steps/#steps-overview & https://circleci.com/docs/reference/configuration-reference/#steps
steps:
# Checkout the code as the first step.
- checkout
- run:
name: "Say hello"
command: "echo Hello, World!"
# Orchestrate jobs using workflows
# See: https://circleci.com/docs/guides/orchestrate/workflows/ & https://circleci.com/docs/reference/configuration-reference/#workflows
workflows:
say-hello-workflow: # This is the name of the workflow, feel free to change it to better match your workflow.
# Inside the workflow, you define the jobs you want to run.
jobs:
- say-hello
-13
View File
@@ -1,13 +0,0 @@
name: build
on:
push:
jobs:
node:
uses: self:KofTwentyTwo/ci-workflows/.gitea/workflows/node.yaml@v1
secrets: inherit
with:
node-version: '22'
# check = typecheck + vitest with the coverage thresholds enforced
commands: |
npm run build
npm run check
+41
View File
@@ -0,0 +1,41 @@
name: Release Obsidian Plugin
on:
push:
tags:
- "*"
permissions:
contents: write
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Use Node.js
uses: actions/setup-node@v4
with:
node-version: "20"
- name: Install dependencies
run: npm ci
- name: Build
run: npm run build
- name: Create release
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TAG_NAME: ${{ github.ref_name }}
run: |
gh release create "$TAG_NAME" \
--title "v$TAG_NAME" \
--notes "## PENNY v$TAG_NAME
See [CHANGELOG](https://github.com/${{ github.repository }}/blob/main/CHANGELOG.md) for details.
### Installation
Download \`main.js\`, \`manifest.json\`, and \`styles.css\` into your vault's \`.obsidian/plugins/obsidian-penny/\` directory." \
main.js manifest.json styles.css
-3
View File
@@ -22,6 +22,3 @@ Thumbs.db
# Test
coverage/
# AI tooling caches (session/transcript data, not portable)
.claude/
-27
View File
@@ -1,27 +0,0 @@
# gitleaks configuration
#
# Allowlist for test-fixture API key placeholders that gitleaks's history
# scan would otherwise flag as real secrets. These strings have only ever
# been used as placeholders in unit tests; no real credential has ever lived
# in this repository.
[extend]
# Inherit gitleaks's default rule set.
useDefault = true
[allowlist]
description = "Test-fixture mock API keys"
regexes = [
# Old placeholder shape that previously matched gitleaks's
# generic-api-key rule (still in git history). See
# test/providers/google.test.ts for current values.
'''AIzaSy-test-key-123''',
'''AIzaSy-(valid|bad|key|test)\b''',
# Current placeholder shape -- wide net so any future
# mock-google-* test fixture is covered.
'''mock-google-[a-z0-9-]+''',
]
paths = [
# Anything under test/ is fixture territory; never real secrets.
'''^test/.+''',
]
-113
View File
@@ -1,113 +0,0 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## What this is
PENNY is an Obsidian community plugin that processes editorial annotations (`%% REWRITE: ... %%`, `%% TONE: ... %%`, etc.) inside novel chapter markdown, calls an LLM to revise the surrounding prose, and writes a new versioned chapter file alongside a review note. It supports Anthropic Claude, Ollama (local), Google Gemini, and OpenAI as providers, with per-tag complexity-tier model routing. Desktop-only (uses Node.js `child_process` for git).
## Common commands
```bash
npm install # install deps
npm run dev # esbuild watch mode -> writes main.js at repo root
npm run build # tsc --noEmit + production esbuild bundle (also auto-deploys, see below)
npm run typecheck # tsc --noEmit
npm run lint # alias for typecheck (no separate eslint script in package.json; eslint config exists)
npm test # vitest run
npm run test:watch # vitest watch
npm run test:coverage # vitest run --coverage (text + json-summary reporters)
```
Run a single test file: `npx vitest run test/parser.test.ts`
Run a single test by name: `npx vitest run -t "parses REWRITE annotation"`
### Build auto-deploy quirk
`npm run build` (production) inspects `../Books/.obsidian/plugins/penny/` and, if the directory exists, copies `main.js`, `styles.css`, and `manifest.json` into it (see `esbuild.config.mjs:48-54`). This is a developer convenience for the maintainer's local vault — do not assume the path exists in CI or for contributors. To test in your own vault, copy or symlink those three files into `<your-vault>/.obsidian/plugins/penny/` manually.
## Architecture
### The hard rule: pure-logic vs Obsidian-shell
The code splits into two zones by **what they import**:
- **Pure-logic modules** (no `obsidian` import, take pre-read strings/objects as parameters):
`parser.ts`, `pipeline.ts`, `assembler.ts`, `drafter.ts`, `versioner.ts`, `frontmatter.ts`, `context.ts`, `reviewer.ts`, `voice-check.ts`, `logger.ts`, `migrate.ts`, `project-config.ts`, `quotes.ts`, `utils.ts`, `types.ts`, and everything under `src/providers/`.
- **Obsidian-shell modules** (free to use Obsidian APIs):
`main.ts`, `commands.ts`, `settings.ts`, `setup-wizard.ts`, `statusbar.ts`, `progress-modal.ts`, `error-modal.ts`, `file-conflict-modal.ts`.
`commands.ts` is the bridge: it reads files via the vault API, calls `runPipeline()` from `pipeline.ts` with everything pre-loaded, then writes results back. **Never add `import { ... } from "obsidian"` to a pure-logic module** — that breaks the test architecture (vitest tests for pure modules do not mock Obsidian).
CONTRIBUTING.md describes a `src/core/` + `src/shell/` directory split, but the codebase is currently a flat `src/` with only `src/providers/` as a subfolder. The boundary is enforced by import discipline, not directory layout.
### Pipeline flow (the heart of the plugin)
1. User runs **PENNY: Process this chapter** (or save hook fires).
2. `commands.ts` reads the chapter, `.version`, `.state.json`, and all context files (style guide, voice tests, characters, outline, wiki) from the vault.
3. It calls `runPipeline()` (`src/pipeline.ts`) with that input plus a `getProvider` lookup callback.
4. The pipeline parses annotations (`parser.ts`), filters out already-processed ones via a content hash stored in `.state.json` (`versioner.ts`), assembles per-annotation context (`context.ts`), builds prompts and calls the LLM (`drafter.ts`), then assembles the final new chapter (`assembler.ts`), updates frontmatter (`frontmatter.ts`), runs voice compliance (`voice-check.ts`), and generates a review note (`reviewer.ts`).
5. `commands.ts` writes the new version file (`ch-05.v2.md`), updates `.version` / `.state.json`, writes the review file, and appends to the activity log.
The pipeline emits real-time `ProgressEvent`s (consumed by `progress-modal.ts`) and supports cancellation via an `isCancelled()` callback.
### Provider system
Four providers live in `src/providers/`: `anthropic.ts`, `ollama.ts`, `google.ts`, `openai.ts`. They all implement the `LLMService` interface from `service.ts` and **do not import `obsidian`**. Instead, they receive an `HttpFn` at construction time. `main.ts` adapts Obsidian's `requestUrl` into that shape:
```ts
this.providerRegistry = createRegistry(async (params) => {
const resp = await requestUrl({...});
return { status, headers, text, json };
});
```
This is why provider tests can mock the HTTP layer directly without any Obsidian shim.
`router.ts` maps annotation tags to complexity tiers (light/standard/heavy) and resolves each tier to a configured provider+model. The `"auto-latest"` model literal is resolved at call-time to the currently-recommended Anthropic model for that tier.
### Versioning model
Each chapter folder contains:
- `ch-NN.v1.md`, `ch-NN.v2.md`, ... — successive versions, never overwritten
- `.version` — plain integer of current version
- `.state.json` — `VersionState` tracking content hashes of already-processed annotations (idempotency)
A processing run that finds zero new annotations writes nothing. Annotation hashes mean re-running on the same file is a no-op even after a crash.
### Settings + per-project overrides
Settings live in Obsidian's `data.json` (loaded in `main.ts:loadSettings`, with legacy field migration via `migrateSettings` in `types.ts`). A project may override a subset by adding a `PENNY.md` at the book folder root — see `project-config.ts`. The settings tab is `settings.ts`; first-run UX is `setup-wizard.ts`.
### Save-hook debouncing
When `autoProcessOnSave` is on, `main.ts` registers a `vault.on("modify")` handler with a per-file 2-second debounce and a `processingFiles: Set<string>` re-entrancy guard. Without both, processing-induced file writes would cascade into infinite re-triggers. If you touch this code, preserve those guards.
## Code conventions
From `CONTRIBUTING.md` and `tsconfig.json` (strict mode, `noUnusedLocals`, `noUnusedParameters`, `noImplicitReturns`):
- **No `any`.** ESLint warns on `@typescript-eslint/no-explicit-any`. Prefer `unknown` and narrow.
- **Named exports only**, no default exports (the one exception is `class PennyPlugin extends Plugin` in `main.ts`, which Obsidian's plugin loader requires as default).
- **Explicit return types** on exported functions.
- **Pure functions in pure-logic modules**: take inputs, return outputs, no side effects. Side effects (vault I/O, network, child_process) belong in shell modules.
- TypeScript target is ES2018; bundled as CJS for Obsidian.
## Testing
- Tests live in `test/` and mirror `src/` (e.g. `src/pipeline.ts` ↔ `test/pipeline-cancel.test.ts`).
- Pure-logic tests **must not** mock Obsidian — that is the whole point of the import boundary. If a test wants to drive the pipeline, it constructs `PipelineInput` directly with raw strings.
- Provider tests mock the injected `HttpFn`, not network or Obsidian.
- Test fixtures (style guide, voice tests, sample chapter) are in `test/fixtures/`.
## Branch model
- **`develop`** — default branch, integration target for all PRs.
- **`main`** — release branch, only receives merges from `develop`.
- Feature branches: `feature/<short-description>` or `fix/<short-description>`, branched from `develop`.
Commits follow Conventional Commits (`feat:`, `fix:`, `docs:`, `refactor:`, `test:`, `chore:`).
## CI
CircleCI with the `kof22/munitor` orb (see `.circleci/config.yml` and `.munitor.yml`). The pipeline runs `npm run build` and `npm test`, plus SAST and SBOM. There is no separate ESLint job — `npm run lint` is aliased to `tsc --noEmit`, so type errors block CI but ESLint warnings do not.
+24 -42
View File
@@ -225,77 +225,59 @@ He mentioned the Tao of Programming, quoting the passage about the master progra
## Scope Rules
Where you place the annotation determines what text PENNY applies it to. The key rule: **annotations go AFTER the text they target.** PENNY always looks upward from the annotation to find its target.
Where you place the annotation determines what passage PENNY applies it to.
### Single paragraph
### Inline (within a paragraph)
Put the annotation on its own line after the paragraph you want to change.
The annotation applies to the sentence or clause immediately before it.
```markdown
She stared at the terminal output. The numbers scrolled past faster than
she could read. Something was wrong with the allocation pattern.
%% EXPAND: Add what she does next. Does she talk to Rabbit? Show her process. %%
The server hummed quietly in the corner %% REWRITE: more specific -- what kind of hum, what frequency %% while she typed.
```
PENNY revises only "The server hummed quietly in the corner" and preserves the rest of the sentence.
### Between paragraphs (own line)
The annotation applies to the paragraph immediately above it.
```markdown
She stared at the terminal output. The numbers scrolled past faster than she could read. Something was wrong with the allocation pattern -- the intervals were shrinking when they should have been growing.
%% EXPAND: Add what she does next. Does she talk to Rabbit? Run a diagnostic? Show her process. %%
The next morning, she biked to campus.
```
PENNY revises the paragraph above the annotation. The paragraph below is untouched.
### Multiple paragraphs (block scope)
### After a heading (line after `#`)
Wrap the target text in `{{ }}` markers, then place the annotation after the closing `}}`. This is the easiest way to target a multi-paragraph passage.
```markdown
{{
I grind the beans. Burr grinder, not blade. Blade grinders chop unevenly.
I fill the kettle. The thermometer clip goes on the side. 205 degrees.
The grinder is the loudest thing in the house at this hour.
I pour it black.
}}
%% REWRITE: Change from pour-over to espresso. She makes americanos --
pulls a shot on a small espresso machine, then adds hot water. Same
precision, same obsession, but espresso. Keep "I pour it black." %%
```
PENNY revises everything between `{{` and `}}`. The markers themselves are not included in the output. You can put a blank line between `}}` and the annotation if you prefer.
### Inline (within a line)
Put the annotation on the same line as the text. It applies to that line only.
```markdown
The server hummed quietly in the corner %% REWRITE: more specific -- what kind of hum, what frequency %% while she typed.
```
PENNY revises only "The server hummed quietly in the corner" and preserves the rest.
### Full section (under a heading)
Put the annotation right after a heading. It targets everything under that heading until the next heading of equal or higher level.
The annotation applies to the entire section up to the next heading of equal or higher level.
```markdown
## The Lab
%% TONE: This whole section is too formal. Loosen it up to match her voice. %%
She entered the laboratory and observed the equipment...
She entered the laboratory and observed the equipment arranged upon the workbenches. The fluorescent lighting cast a sterile glow across the room. She proceeded to her workstation and initiated the boot sequence.
```
PENNY revises everything from "She entered..." to the next `##` heading.
### Multiple annotations on the same passage
Each annotation is processed independently. If multiple annotations target the same passage, each generates a separate revision pass applied in sequence.
**Planned for future version.** Currently, each annotation is processed independently. If multiple annotations target the same passage, each one generates a separate revision pass applied in sequence (bottom to top).
```markdown
"I suppose we might consider an alternative approach," she said thoughtfully.
%% DIALOG: She doesn't talk like this. Too formal, too hedged. %%
%% CHARACTER: Check her character sheet. She's direct and blunt. %%
%% TONE: Match the voice tests. %%
```
For best results when multiple concerns apply to one passage, combine them into a single annotation with all instructions.
Each annotation above is processed as a separate revision. The DIALOG annotation is applied first, then CHARACTER, then TONE. For best results when multiple concerns apply to one passage, combine them into a single annotation with all instructions.
## Tips for Writing Good Instructions
File diff suppressed because it is too large. Load diff
@@ -1,110 +0,0 @@
# Design: Retry on 429 / transient 5xx with backoff (#15)
**Status:** Draft, awaiting approval
**Issue:** [#15](https://github.com/KofTwentyTwo/obsidian-penny/issues/15)
**Date:** 2026-04-27
**Branch (planned):** `feature/issue-15-retry-backoff` from `develop`
## Goal
Make PENNY's LLM calls survive transient failures. Today, a single 429 or 5xx aborts the entire annotation pass and pollutes the new chapter version with `AGENT-ERROR` markers (`src/pipeline.ts:342-373`) for revisions that would have succeeded on retry. This is a daily-use blocker the moment #9 (parallel annotations) lands, because concurrent Anthropic calls will routinely hit the tier-1 50 RPM / 40K TPM ceiling.
## Architecture
A new `withRetry<T>(fn, opts)` helper lives in `src/providers/node-stream.ts` (same module as `TimeoutError`, `AbortError`, `withTimeout`). Each provider's `complete()` extracts its current request body into a private `doOneAttempt()` method and wraps the call:
```ts
const maxRetries = request.maxRetries ?? 3;
return withRetry(() => this.doOneAttempt(request), {
maxAttempts: maxRetries + 1,
canStillRetry: () => !started,
signal: request.signal,
onRetry: request.onRetry,
});
```
A new `HttpError` class (also in `node-stream.ts`) carries `status`, `headers`, and `body` so the retry helper can read `Retry-After`. `streamRequest`'s return shape is extended with `headers: Record<string, string>` so the streaming path can populate `HttpError.headers` the same way the non-streaming path does. Both paths route HTTP errors through the same `buildHttpError(status, text, headers)` helper.
Retry placement is **inside each provider's `complete()`**, around the entire request path (streaming and non-streaming). This keeps the retry decision close to the code that knows what kind of error was thrown, mirrors the surface change made for `withTimeout` in #18, and avoids a new abstraction layer.
## Components
1. **`HttpError extends Error`** — `{ status: number; headers: Record<string,string>; body: string }`. Replaces the plain `Error` returned by every provider's `buildHttpError`.
2. **`withRetry<T>(fn, opts)`** — runs `fn`, classifies thrown errors, computes delay, sleeps cancellation-aware, retries up to `maxAttempts`. Options: `{ maxAttempts: number; canStillRetry?: () => boolean; signal?: AbortSignal; onRetry?: (info: RetryInfo) => void }`.
3. **`isRetriable(err): boolean`** — true when `err instanceof HttpError && (err.status === 429 || err.status === 408 || err.status >= 500)`, OR when `err` is a transport error (none of `HttpError | TimeoutError | AbortError`). False for `TimeoutError`, `AbortError`, other 4xx.
4. **`backoffDelay(err, attempt): number`** — `attempt` is 0-indexed across retries (0 = wait before retry #1, 1 = wait before retry #2, ...). If `HttpError` and `Retry-After` parses cleanly (seconds or HTTP-date), return that delay. Else `random(0, 1000 * 2^attempt)` (full jitter): max 1s before retry #1, max 2s before retry #2, max 4s before retry #3. Both branches capped at 60000ms.
5. **`CompletionRequest` additions** — `maxRetries?: number` and `onRetry?: (info: { attempt: number; waitMs: number; reason: string }) => void`.
6. **`PennySettings.maxRetries`** — default 3, range 0–5 (0 disables). Exposed in Settings → Behavior next to `requestTimeoutMs`. Threaded into `complete()` calls from `pipeline.ts`.
7. **Pipeline integration** — `pipeline.ts` passes `maxRetries` from settings, supplies an `onRetry` callback that emits `ProgressEvent("retry", { attempt, waitMs, reason })`. Providers stay free of Obsidian dependencies.
## Data flow (one annotation, 429 → 200)
1. `pipeline.ts` calls `service.complete(request)` with `maxRetries` from settings and an `onRetry` that emits a `ProgressEvent("retry", ...)`.
2. Provider's `complete()` initializes `started = false`, wraps `onToken` so the first call flips `started = true`.
3. Calls `withRetry(() => doOneAttempt(...), { maxAttempts: 4 /* maxRetries:3 + 1 initial */, canStillRetry: () => !started, signal, onRetry })`.
4. **Attempt 1 (429 with `Retry-After: 30`):** non-2xx response → `buildHttpError` constructs `HttpError(429, { "retry-after": "30" }, body)` and throws. `withRetry` catches; `isRetriable(err)` is true; `canStillRetry()` is true; `backoffDelay(err, 0)` returns 30000ms. Emits `onRetry({ attempt: 2, waitMs: 30000, reason: "429" })`, `LOG.info("retrying after 429", { provider, attempt, waitMs })`, sleeps 30s with abort-signal listener.
5. **Attempt 2 (200):** returns `CompletionResponse` normally.
Cancellation during the sleep wakes immediately and throws `AbortError`. If any token reached the user before the error (mid-stream failure), `started === true`, `canStillRetry()` returns false, and the error propagates without retry.
## Error handling
| Error kind | Retriable? |
|---|---|
| `HttpError` with status 429 | yes |
| `HttpError` with status 408 | yes |
| `HttpError` with status 5xx | yes |
| `HttpError` with other 4xx (401, 403, 400, 404, ...) | no |
| Transport error (no recognized class) | yes |
| `TimeoutError` (from #18) | no — user chose the timeout; retrying doubles the wait |
| `AbortError` (user cancellation) | no |
| Any error after first `onToken` callback fired | no — retrying would re-emit tokens |
After all attempts exhausted, throw the last error unchanged.
**Naming convention.** The setting is `maxRetries` — the number of retries on top of the initial call. Default `maxRetries: 3` means up to 4 total calls (1 initial + 3 retries). `maxRetries: 0` disables retry entirely (single attempt, surface the first error). The internal helper signature uses `maxAttempts = maxRetries + 1` so its loop bound is unambiguous.
## Testing
New `test/providers/retry.test.ts`:
- 429 → 429 → 200 succeeds with mock `httpFn`.
- `Retry-After: 5` honored; malformed `Retry-After: ABCD` falls back to exponential.
- Honors HTTP-date `Retry-After` (e.g. `Wed, 21 Oct 2026 07:28:00 GMT`) within the 60s cap.
- 401 propagates immediately, no retry.
- All 5xx retriable (501, 502, 503, 504 covered by representative tests).
- `TimeoutError` propagates, no retry.
- `AbortSignal` aborted mid-sleep → prompt `AbortError`.
- `canStillRetry` returns false (tokens emitted) → no retry on 503.
- `maxRetries: 0` makes a single attempt and throws the first error.
- Per-provider smoke tests proving wiring (one minimal test each for anthropic/openai/google/ollama).
Existing tests remain green; retry is transparent on the success path. Coverage expected to lift from 91% to ~93%; the same PR ratchets `vitest.config.ts` line/statement/function/branch thresholds and `.munitor.yml` `min_instruction` per the standing sprint pattern.
## Files affected
| Path | Change |
|---|---|
| `src/providers/node-stream.ts` | Add `HttpError`, `withRetry`, `isRetriable`, `backoffDelay`. Extend `streamRequest` return shape with `headers`. |
| `src/providers/anthropic.ts` | Refactor `complete()` body into `doOneAttempt`, wrap with `withRetry`. Update `buildHttpError(status, text, headers)` to populate `HttpError`. |
| `src/providers/openai.ts` | Same refactor as anthropic. |
| `src/providers/google.ts` | Same refactor. |
| `src/providers/ollama.ts` | Same refactor. |
| `src/providers/service.ts` | Add `maxRetries?: number` and `onRetry?: (info) => void` to `CompletionRequest`. |
| `src/types.ts` | Add `maxRetries: number` to `PennySettings`, default 3 in `migrateSettings`. |
| `src/settings.ts` | Settings → Behavior UI for max retries (slider 0–5, default 3). |
| `src/pipeline.ts` | Pass `maxRetries` from settings, wire `onRetry` to a `ProgressEvent("retry", ...)`. |
| `src/progress-modal.ts` | Render "Retrying after rate limit (attempt N/M, waiting Xs)…" when retry events arrive. |
| `test/providers/retry.test.ts` | New file covering the cases above. |
| `test/providers/{anthropic,openai,google,ollama}.test.ts` | Targeted updates for the new error shape and `doOneAttempt` refactor. |
| `vitest.config.ts`, `.munitor.yml` | Coverage threshold ratchet at PR-finalization time. |
## Open questions
None at design time. The retriable-set decision (5xx range, 408, transport errors; 4xx-other / `TimeoutError` / `AbortError` / post-onToken excluded) follows industry default behavior and the existing repo idioms.
## Out of scope
- Concurrency-aware retry coordination (single global token bucket across parallel calls). #9 will likely want this, but it can layer on top of `withRetry` later.
- Per-provider retry policy customization (e.g., "retry Ollama 5 times because it's local"). Single global setting is fine for now.
- Persistence of retry stats. Activity log captures success/failure; not adding a per-attempt audit.
-12
View File
@@ -1,8 +1,6 @@
import esbuild from "esbuild";
import process from "process";
import builtins from "builtin-modules";
import { copyFileSync, existsSync } from "fs";
import { resolve } from "path";
const banner = `/*
PENNY - Prose Engine for Narrative, Notes, and Yarns
@@ -43,16 +41,6 @@ const context = await esbuild.context({
if (prod) {
await context.rebuild();
// Auto-deploy to the Obsidian vault's plugin folder if it exists
const vaultPluginDir = resolve("..", "Books", ".obsidian", "plugins", "penny");
if (existsSync(vaultPluginDir)) {
copyFileSync("main.js", resolve(vaultPluginDir, "main.js"));
copyFileSync("styles.css", resolve(vaultPluginDir, "styles.css"));
copyFileSync("manifest.json", resolve(vaultPluginDir, "manifest.json"));
console.log(`Deployed to ${vaultPluginDir}`);
}
process.exit(0);
} else {
await context.watch();
-26
View File
@@ -1,26 +0,0 @@
import eslint from "@eslint/js";
import tseslint from "typescript-eslint";
export default tseslint.config(
eslint.configs.recommended,
...tseslint.configs.recommended,
{
files: ["src/**/*.ts"],
languageOptions: {
parserOptions: {
project: "./tsconfig.json",
},
},
rules: {
"@typescript-eslint/no-unused-vars": ["warn", {
argsIgnorePattern: "^_",
varsIgnorePattern: "^_",
}],
"@typescript-eslint/no-explicit-any": "warn",
"preserve-caught-error": "off",
},
},
{
ignores: ["main.js", "dist/", "node_modules/", "test/", "*.mjs"],
}
);
+30 -1375
View File
File diff suppressed because it is too large. Load diff
+1 -8
View File
@@ -5,13 +5,10 @@
"main": "main.js",
"scripts": {
"dev": "node esbuild.config.mjs",
"build": "tsc -noEmit && node esbuild.config.mjs production",
"typecheck": "tsc --noEmit",
"lint": "tsc --noEmit",
"build": "tsc -noEmit -skipLibCheck && node esbuild.config.mjs production",
"test": "vitest run",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage",
"check": "npm run typecheck && npm run test:coverage",
"version": "node version-bump.mjs && git add manifest.json versions.json"
},
"keywords": [
@@ -27,16 +24,12 @@
"author": "James Maes",
"license": "MIT",
"devDependencies": {
"@eslint/js": "^10.0.1",
"@types/node": "^20.0.0",
"@vitest/coverage-v8": "^4.1.4",
"builtin-modules": "^3.3.0",
"esbuild": "^0.28.0",
"eslint": "^10.2.0",
"obsidian": "latest",
"tslib": "2.6.2",
"typescript": "5.4.5",
"typescript-eslint": "^8.58.2",
"vitest": "^4.1.4"
}
}
+5 -28
View File
@@ -1,30 +1,15 @@
/**
* PENNY - Version Assembly
*
* Builds a new chapter version by splicing LLM-produced revisions into
* the original chapter text. Called by pipeline.ts after all annotations
* have been processed.
*
* Key design decisions:
* - Processes revisions bottom-up (highest lineStart first) so that
* earlier line numbers remain valid as later ranges are replaced.
* - Detects overlapping annotation ranges and keeps the larger-scope
* one, reporting the smaller as a skipped overlap in the result.
* - Strips processed actionable annotations from the output while
* preserving passthrough annotations (NOTE, RESEARCH).
* - Inserts `%% REVISED(vN) %%` markers after each replacement for
* traceability.
*
* Builds a new version of a chapter from processed revisions.
* Works bottom-up to preserve line numbers during replacement.
* Pure function -- no Obsidian API dependencies.
*/
import type { AnnotatedSection } from "./types";
import { PASSTHROUGH_TAGS } from "./types";
/**
* Non-global regex pattern for matching annotations. A new RegExp with the 'g'
* flag is created from this source per use-site to avoid shared lastIndex state.
*/
/** Non-global regex pattern for matching annotations. A new RegExp is created per use. */
const ANNOTATION_PATTERN = /%%\s*([A-Z]+)\s*:\s*(.*?)\s*%%/;
function wordCount(text: string): number {
@@ -37,23 +22,15 @@ function isPassthrough(tag: string): boolean {
return (PASSTHROUGH_TAGS as readonly string[]).includes(tag);
}
/** Info about an annotation that was skipped because its range overlapped a larger annotation. */
/** Info about an annotation that was skipped due to overlap. */
export interface SkippedOverlap {
/** The tag of the skipped annotation. */
tag: string;
/** The starting line of the skipped annotation. */
line: number;
}
/**
* Result of assembleNewVersion.
* Contains the fully assembled chapter text and metadata about any
* annotations that were dropped due to overlapping ranges.
*/
/** Result of assembleNewVersion, including assembled text and any skipped overlaps. */
export interface AssemblyResult {
/** The new chapter content with revisions spliced in and processed annotations removed. */
content: string;
/** Annotations that were skipped because they overlapped a larger-scope annotation. */
skippedOverlaps: SkippedOverlap[];
}
+143 -558
View File
File diff suppressed because it is too large. Load diff
+8 -33
View File
@@ -1,36 +1,17 @@
/**
* PENNY - Context Assembly (pure logic)
*
* Assembles reference material (voice tests, style guide, outlines,
* character sheets, wiki entries, series bible, themes) into a single
* AssembledContext object for an LLM revision call.
*
* Called by pipeline.ts. The Obsidian file-reading happens in commands.ts;
* this module takes pre-read string contents and assembles them by priority
* with token budgeting so the prompt stays within the provider's context
* window.
*
* Priority order (highest first):
* 1. Full chapter text (always included)
* 2. Voice test examples
* 3. Style guide
* 4. Book plot outline
* 5. Character sheets
* 6. Wiki/lore entries
* 7. Series bible
* 8. Themes
*
* Lower-priority items are dropped if the token budget is exceeded.
*
* Assembles context from file contents for a revision prompt.
* The Obsidian file-reading happens in the plugin shell; this module
* takes string contents and assembles them by priority with token budgeting.
* Pure function -- no Obsidian API dependencies.
*/
import type { AnnotatedSection, AssembledContext, PennySettings } from "./types";
/**
* Pre-read file contents provided to the context assembler.
* All fields are raw string contents (already read from the vault by commands.ts).
* Optional fields are omitted when the corresponding setting path is empty.
* Files provided to the context assembler.
* All fields are string contents (already read from the vault).
*/
export interface ContextFiles {
chapter: string;
@@ -61,14 +42,8 @@ export function estimateTokens(text: string, multiplier = 1.33): number {
*
* The voice tests file is expected to have sections headed by character names
* (e.g. `## Tim`, `## Darin`). If any detected character name appears in a
* heading, only those matching sections are included. If no match, the full
* file is returned so the LLM still has voice reference material.
*
* Called by commands.ts before assembleContext() to pre-filter voice tests.
*
* @param voiceTestsContent - Full content of the voice tests file
* @param characters - Lowercased character names detected in the scene
* @returns Filtered voice test content (matching sections only, or full file if no match)
* heading, that section is included. If no match, the full file is returned
* (up to a reasonable limit).
*/
export function selectVoiceTestSection(
voiceTestsContent: string,
@@ -137,7 +112,7 @@ export function selectVoiceTestSection(
*/
export function assembleContext(
files: ContextFiles,
_annotation: AnnotatedSection,
annotation: AnnotatedSection,
settings: PennySettings,
tokenMultiplier = 1.33,
): AssembledContext {
+8 -23
View File
@@ -1,32 +1,19 @@
/**
* PENNY - Prompt Construction & Provider Dispatch
*
* Builds the system prompt (from the template + assembled context) and the
* user prompt (the focused task for a single annotation), then dispatches
* the completion request to an LLM provider.
*
* Called by pipeline.ts once per actionable annotation.
*
* System prompt construction:
* - Replaces {placeholder} tokens in the template with context values
* - Removes entire sections (heading + placeholder) when context is empty
* - Cleans up double blank lines left by section removal
*
* User prompt construction:
* - Contains the passage to revise, the author's instruction, and a
* directive to output only the replacement text
*
* Pure functions -- no Obsidian API dependencies (callProvider accepts
* the provider as a parameter via dependency injection).
* Build the system and user prompts from assembled context and an annotation.
* Dispatch completion requests to the active LLM provider.
* Pure functions -- no Obsidian API dependencies (except callProvider which
* accepts the provider as a parameter).
*/
import type { AssembledContext, AnnotatedSection } from "./types";
import type { CompletionRequest, CompletionResponse } from "./providers/service";
/**
* Registry of all {placeholder} tokens recognized in the system prompt template.
* Each entry maps a placeholder string to its display label (used for section
* heading matching during removal) and its corresponding AssembledContext field.
* All placeholders that can appear in the system prompt template.
* Each maps to either a field on AssembledContext or a field on
* AnnotatedSection.
*/
const SECTION_PLACEHOLDERS: Record<string, { label: string; contextKey?: keyof AssembledContext }> = {
"{voice_rules}": { label: "CRITICAL VOICE RULES", contextKey: "voiceRules" },
@@ -35,8 +22,6 @@ const SECTION_PLACEHOLDERS: Record<string, { label: string; contextKey?: keyof A
"{outline}": { label: "Plot Outline", contextKey: "outline" },
"{characters}": { label: "Character References", contextKey: "characters" },
"{wiki}": { label: "Wiki/Lore References", contextKey: "wiki" },
"{series_bible}": { label: "Series Bible", contextKey: "seriesBible" },
"{themes}": { label: "Themes", contextKey: "themes" },
"{chapter}": { label: "Current Chapter", contextKey: "chapter" },
};
@@ -130,7 +115,7 @@ export async function callProvider(
return provider.complete(request);
}
/** Escape special regex characters in a string so it can be used in a RegExp constructor. */
/** Escape special regex characters in a string. */
function escapeRegExp(str: string): string {
return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
-85
View File
@@ -1,85 +0,0 @@
/**
* PENNY - Error Modal
*
* Obsidian Modal subclass for displaying persistent error information.
* Unlike Obsidian's Notice (which auto-dismisses), this modal stays open
* until the user explicitly closes it, giving them time to read and
* copy error details.
*
* Shows: error message, optional expandable stack trace / API response,
* and an optional path to the activity log for further investigation.
*
* Used by commands.ts when processChapter() catches an unexpected error.
*/
import { Modal, App } from "obsidian";
export class PennyErrorModal extends Modal {
private title: string;
private errorMessage: string;
private details: string;
private logPath: string;
constructor(app: App, opts: {
title?: string;
error: string;
details?: string;
logPath?: string;
}) {
super(app);
this.title = opts.title ?? "PENNY Error";
this.errorMessage = opts.error;
this.details = opts.details ?? "";
this.logPath = opts.logPath ?? "";
}
onOpen(): void {
const { contentEl } = this;
contentEl.empty();
contentEl.addClass("penny-error-modal");
contentEl.createEl("h3", { text: this.title });
// Error message
const errorBox = contentEl.createEl("div", { cls: "penny-error-box" });
errorBox.createEl("p", { text: this.errorMessage, cls: "penny-error-message" });
// Details (stack trace, API response, etc.)
if (this.details) {
const detailsEl = contentEl.createEl("details", { cls: "penny-error-details" });
detailsEl.createEl("summary", { text: "Full Details" });
const pre = detailsEl.createEl("pre");
pre.createEl("code", { text: this.details });
}
// Log path link
if (this.logPath) {
contentEl.createEl("p", {
text: `Activity log: ${this.logPath}`,
cls: "penny-error-log-path",
});
}
// Close button
const buttonRow = contentEl.createEl("div", { cls: "penny-progress-buttons" });
const closeBtn = buttonRow.createEl("button", { text: "Close" });
closeBtn.addEventListener("click", () => this.close());
}
onClose(): void {
this.contentEl.empty();
}
}
/**
* Show a PENNY error in a persistent modal.
* Use this instead of `new Notice()` for errors that need user attention.
*
* @param app - The Obsidian App instance
* @param error - Primary error message
* @param details - Optional stack trace or additional detail (shown in expandable section)
* @param logPath - Optional path to the activity log file for reference
*/
export function showPennyError(app: App, error: string, details?: string, logPath?: string): void {
new PennyErrorModal(app, { error, details, logPath }).open();
}
-153
View File
@@ -1,153 +0,0 @@
/**
* PENNY - File Conflict Modal
*
* When PENNY tries to create a file that already exists, this modal
* presents the user with options: Overwrite, Rename (auto-increment),
* or Cancel.
*/
import { Modal, App, TFile } from "obsidian";
import type PennyPlugin from "./main";
import { showPennyError } from "./error-modal";
/** The user's chosen resolution for a file conflict. */
export type FileConflictChoice = "overwrite" | "rename" | "cancel";
/**
* Modal that displays when a file creation conflicts with an existing file.
* Returns a promise that resolves with the user's choice.
*/
export class FileConflictModal extends Modal {
private resolveChoice: ((choice: FileConflictChoice) => void) | null = null;
private filePath: string;
constructor(app: App, filePath: string) {
super(app);
this.filePath = filePath;
}
onOpen(): void {
const { contentEl } = this;
contentEl.empty();
contentEl.addClass("penny-error-modal");
contentEl.createEl("h3", { text: "File Already Exists" });
const messageBox = contentEl.createEl("div", { cls: "penny-conflict-box" });
messageBox.createEl("p", {
text: this.filePath,
cls: "penny-conflict-path",
});
messageBox.createEl("p", {
text: "How would you like to proceed?",
});
const buttonRow = contentEl.createEl("div", { cls: "penny-progress-buttons" });
const overwriteBtn = buttonRow.createEl("button", { text: "Overwrite" });
overwriteBtn.addEventListener("click", () => {
this.resolve("overwrite");
});
const renameBtn = buttonRow.createEl("button", { text: "Rename" });
renameBtn.addEventListener("click", () => {
this.resolve("rename");
});
const cancelBtn = buttonRow.createEl("button", { text: "Cancel" });
cancelBtn.addEventListener("click", () => {
this.resolve("cancel");
});
}
onClose(): void {
// If the modal is closed without a button click, treat as cancel
this.resolve("cancel");
this.contentEl.empty();
}
/** Wait for the user to pick an option. */
awaitChoice(): Promise<FileConflictChoice> {
return new Promise<FileConflictChoice>((resolve) => {
this.resolveChoice = resolve;
});
}
private resolve(choice: FileConflictChoice): void {
if (this.resolveChoice) {
const cb = this.resolveChoice;
this.resolveChoice = null;
this.close();
cb(choice);
}
}
}
/**
* Find the next available numbered path for a file.
* e.g. "folder/file.md" -> "folder/file-2.md", "folder/file-3.md", etc.
*/
function findNextAvailablePath(plugin: PennyPlugin, originalPath: string): string {
const dotIndex = originalPath.lastIndexOf(".");
const base = dotIndex >= 0 ? originalPath.slice(0, dotIndex) : originalPath;
const ext = dotIndex >= 0 ? originalPath.slice(dotIndex) : "";
let counter = 2;
let candidate = `${base}-${counter}${ext}`;
while (plugin.app.vault.getAbstractFileByPath(candidate)) {
counter++;
candidate = `${base}-${counter}${ext}`;
}
return candidate;
}
/**
* Safely create a file, showing a conflict resolution dialog if the file
* already exists.
*
* Returns the created/modified TFile, or null if the user cancelled.
* For any non-conflict error, shows the error modal with full details.
*/
export async function safeCreateFile(
plugin: PennyPlugin,
path: string,
content: string,
): Promise<TFile | null> {
try {
return await plugin.app.vault.create(path, content);
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
// Check if the error is a "file already exists" conflict
if (message.toLowerCase().includes("file already exists") || message.toLowerCase().includes("already exists")) {
const modal = new FileConflictModal(plugin.app, path);
modal.open();
const choice = await modal.awaitChoice();
switch (choice) {
case "overwrite": {
const existing = plugin.app.vault.getAbstractFileByPath(path);
if (existing && existing instanceof TFile) {
await plugin.app.vault.modify(existing, content);
return existing;
}
// Shouldn't happen, but fall back to create
return await plugin.app.vault.create(path, content);
}
case "rename": {
const newPath = findNextAvailablePath(plugin, path);
return await plugin.app.vault.create(newPath, content);
}
case "cancel":
return null;
}
}
// Non-conflict error: show the error modal
const stack = err instanceof Error ? err.stack ?? "" : "";
showPennyError(plugin.app, `Failed to create file: ${path}`, `${message}\n\n${stack}`);
return null;
}
}
+16 -68
View File
@@ -1,20 +1,11 @@
/**
* PENNY - YAML Frontmatter
*
* Parse, update, and serialize YAML frontmatter in chapter files.
* Called by pipeline.ts to read/update agent-managed fields, and by
* commands.ts for character detection and word counting.
* Parse and update chapter frontmatter.
* Pure functions -- no Obsidian API dependencies.
*
* Uses a simple YAML subset parser (key: value pairs, arrays) rather than
* pulling in a full YAML library, keeping the plugin dependency-free.
* Supports: strings, numbers, booleans, inline arrays `[a, b]`, and
* block arrays with ` - item` continuation lines.
*
* Key design choice: preserves original key order when re-serializing,
* so author-authored frontmatter fields stay in place and only agent-added
* fields are appended at the end.
*
* Pure functions -- no Obsidian API dependencies.
*/
import type { ChapterFrontmatter } from "./types";
@@ -28,12 +19,6 @@ const FRONTMATTER_RE = /^---\r?\n([\s\S]*?)\r?\n---/;
* Returns the parsed frontmatter object and the body text (everything after
* the closing `---`). If no frontmatter is found, returns an empty
* frontmatter object and the full content as body.
*
* @param content - Full file content including the `---` delimiters
* @returns An object with:
* - `frontmatter`: Parsed key-value pairs
* - `body`: Everything after the closing `---`
* - `keyOrder`: Keys in the order they appeared (for round-trip fidelity)
*/
export function parseFrontmatter(content: string): {
frontmatter: ChapterFrontmatter;
@@ -70,8 +55,7 @@ export function parseFrontmatter(content: string): {
}
// Key: value pair
// Allow hyphens in key names (e.g., book-appearance, agent_last_revised)
const kvMatch = line.match(/^([a-zA-Z_][a-zA-Z0-9_-]*)\s*:\s*(.*)/);
const kvMatch = line.match(/^([a-zA-Z_][a-zA-Z0-9_]*)\s*:\s*(.*)/);
if (!kvMatch) continue;
const key = kvMatch[1];
@@ -112,7 +96,6 @@ export function parseFrontmatter(content: string): {
return { frontmatter, body, keyOrder };
}
/** Parse a raw YAML value string into a typed JS value (string, number, or boolean). */
function parseYamlValue(raw: string): string | number | boolean {
// Remove surrounding quotes.
if ((raw.startsWith('"') && raw.endsWith('"')) || (raw.startsWith("'") && raw.endsWith("'"))) {
@@ -139,7 +122,7 @@ function needsYamlQuoting(value: string): boolean {
// Starts with a YAML-special character
if (/^[@!*&'"]/.test(value)) return true;
// Contains characters that could break YAML parsing
if (/[:#[\]{}"']/.test(value)) return true;
if (/[:#\[\]{}"']/.test(value)) return true;
return false;
}
@@ -154,12 +137,6 @@ function quoteYamlValue(value: string): string {
/**
* Serialize frontmatter and body back into a markdown string.
*
* @param frontmatter - The frontmatter key-value pairs to serialize
* @param body - The body text to append after the closing `---`
* @param keyOrder - Optional key ordering from the original parse (for round-trip fidelity).
* Keys not in this list are appended after ordered keys.
* @returns Complete markdown string: `---\nfrontmatter\n---\nbody`
*/
export function serializeFrontmatter(frontmatter: ChapterFrontmatter, body: string, keyOrder?: string[]): string {
const yamlLines: string[] = [];
@@ -213,10 +190,6 @@ export function serializeFrontmatter(frontmatter: ChapterFrontmatter, body: stri
/**
* Update agent-managed fields on the frontmatter.
* Returns a new object; does not mutate the input.
*
* @param frontmatter - The current frontmatter object
* @param updates - Fields to merge (e.g. agent_version, wordcount, status)
* @returns A new ChapterFrontmatter with the updates applied
*/
export function updateAgentFields(
frontmatter: ChapterFrontmatter,
@@ -228,14 +201,8 @@ export function updateAgentFields(
/**
* Count prose words in the chapter body.
*
* Only counts text after the `proseMarker` comment (e.g. `<!-- Prose begins below -->`).
* If the marker is not found, counts all words in the body.
* Strips markdown comments (%% ... %%), HTML comments (<!-- ... -->),
* and heading markers before counting.
*
* @param content - Full chapter content (with frontmatter)
* @param proseMarker - The HTML comment that marks the start of prose
* @returns Word count of the prose section
* Only counts text after the `proseMarker` comment. If the marker is not
* found, counts all words in the body.
*/
export function countProseWords(content: string, proseMarker: string): number {
let text = content;
@@ -262,17 +229,11 @@ export function countProseWords(content: string, proseMarker: string): number {
/**
* Detect characters mentioned in the chapter.
*
* Two detection strategies:
* 1. Parse the `focus` frontmatter field (comma/plus/ampersand-separated names).
* 1. Parse the `focus` frontmatter field (comma-separated names).
* 2. Scan dialogue for attribution patterns like `NAME said`, `said NAME`,
* `NAME asked`, etc.
*
* Used by the pipeline to select relevant voice test sections and
* character sheets for the LLM context.
*
* @param content - Full chapter content to scan for dialogue attributions
* @param focusField - Value of the `focus` frontmatter field (may be empty)
* @returns Deduplicated, lowercased array of character identifiers
* Returns a deduplicated, lowercased array of character identifiers.
*/
export function detectCharacters(content: string, focusField: string): string[] {
const chars = new Set<string>();
@@ -285,30 +246,17 @@ export function detectCharacters(content: string, focusField: string): string[]
}
}
// Verb list shared across attribution directions. Includes shouted variants.
const VERBS = "said|asked|whispered|muttered|yelled|replied|shouted|murmured|called|snapped|growled|sighed|laughed|hissed|breathed|cried";
// Capture name shapes (issue #33):
// - Single PascalCase: "Mary"
// - Hyphenated: "Mary-Anne"
// - Apostrophe names: "O'Brien", "M'Lady"
// - All-caps shouted: "MARY"
// - Two-word names: "Mary Jane", "Mary-Anne O'Connor"
// The hyphen/apostrophe alternative comes first so single-letter prefixes
// (the "O" in "O'Brien") are allowed only when followed by a separator and
// a capitalized continuation -- avoids matching the pronoun "I said".
const SINGLE = "(?:[A-Z][a-zA-Z]*['-][A-Z][a-zA-Z]+|[A-Z][a-zA-Z]+|[A-Z]{2,})";
const NAME = `${SINGLE}(?:\\s+${SINGLE})?`;
// Forward: `Name said` / `Name shouted` etc.
const forwardRe = new RegExp(`(?:^|\\s)(${NAME})\\s+(?:${VERBS})\\b`, "gm");
for (const match of content.matchAll(forwardRe)) {
chars.add(match[1].toLowerCase());
// From dialogue attribution patterns.
const attributionRe = /(?:^|\s)([A-Z][a-z]+)\s+(?:said|asked|whispered|muttered|yelled|replied|shouted|murmured|called|snapped)/gm;
let m: RegExpExecArray | null;
while ((m = attributionRe.exec(content)) !== null) {
chars.add(m[1].toLowerCase());
}
// Reverse: `said Name`
const reverseRe = new RegExp(`(?:${VERBS})\\s+(${NAME})\\b`, "gm");
for (const match of content.matchAll(reverseRe)) {
chars.add(match[1].toLowerCase());
const reverseRe = /(?:said|asked|whispered|muttered|yelled|replied|shouted|murmured|called|snapped)\s+([A-Z][a-z]+)/gm;
while ((m = reverseRe.exec(content)) !== null) {
chars.add(m[1].toLowerCase());
}
return Array.from(chars);
+3 -40
View File
@@ -1,17 +1,11 @@
/**
* PENNY - Activity Logger
*
* Provides two logging facilities:
* 1. JSONL activity log entries -- one JSON object per processing pass,
* appended to `{activityLogFolder}/{book}-activity.jsonl` by commands.ts.
* 2. Console logging -- level-filtered messages to the developer console
* (controlled by the `logLevel` setting).
*
* Actual file I/O (reading/appending the JSONL file) is handled by
* commands.ts via Obsidian's vault API. This module is pure formatting.
* Formats activity log entries as JSONL for append-only logging.
* Actual file I/O is handled by the command layer via Obsidian's vault API.
*/
import type { ActivityLogEntry, LogLevel } from "./types";
import type { ActivityLogEntry } from "./types";
/**
* Serialize an activity log entry to a single JSONL line (JSON + newline).
@@ -40,37 +34,6 @@ export function createLogEntry(
};
}
// ---------------------------------------------------------------------------
// Console logging with level filtering
// ---------------------------------------------------------------------------
const LOG_LEVELS: Record<LogLevel, number> = { debug: 0, info: 1, warn: 2, error: 3, off: 4 };
/**
* Log a message to the developer console, filtered by the configured log level.
*
* @param level - Severity of this message
* @param settingLevel - The user's configured minimum log level
* @param message - Human-readable message
* @param data - Optional additional data to log
*/
export function pennyLog(level: LogLevel, settingLevel: LogLevel, message: string, ...data: unknown[]): void {
if (LOG_LEVELS[level] >= LOG_LEVELS[settingLevel]) {
// The first argument to console.* is a string LITERAL per case so that
// taint-flow analyzers (semgrep's unsafe-formatstring rule) cannot
// construct a path from runtime values to a format-string sink. The
// prefix has always been derived from the closed LogLevel enum so this
// was never a real risk -- but expressing it as four literals keeps the
// SAST gate quiet without per-line rule suppressions.
switch (level) {
case "debug": console.log("[PENNY DEBUG]", message, ...data); break;
case "info": console.info("[PENNY INFO]", message, ...data); break;
case "warn": console.warn("[PENNY WARN]", message, ...data); break;
case "error": console.error("[PENNY ERROR]", message, ...data); break;
}
}
}
/**
* Derive the log file path for a given book within the activity log folder.
*
+20 -131
View File
@@ -1,27 +1,16 @@
/**
* PENNY - Prose Engine for Narrative, Notes, and Yarns
*
* Main plugin entry point. Extends Obsidian's Plugin class to bootstrap
* the entire plugin: loading/saving settings, creating the LLM provider
* registry, registering commands, setting up the status bar and ribbon
* icon, and wiring the auto-process-on-save hook.
*
* Architecture:
* - Settings are loaded from Obsidian's data.json with migration for
* legacy fields (see migrateSettings in types.ts).
* - The provider registry is created with Obsidian's requestUrl as the
* HTTP transport layer, keeping providers decoupled from Obsidian.
* - Commands, status bar, and ribbon icon are registered in onload().
* - The save hook uses a per-file debounce (2s) and a re-entrancy guard
* (processingFiles set) to prevent cascading triggers.
* Main plugin entry point. Extends Obsidian's Plugin class to provide
* AI-powered prose revision, annotation processing, and project management
* for novel writing in Obsidian.
*
* @module main
*/
import { Menu, Plugin, TFile, requestUrl } from "obsidian";
import { Plugin, Notice, TFile, requestUrl } from "obsidian";
import { PennySettingTab } from "./settings";
import { PennyStatusBar } from "./statusbar";
import { SetupWizardModal } from "./setup-wizard";
import { registerCommands, processChapter, requireProvider } from "./commands";
import { DEFAULT_SETTINGS, migrateSettings } from "./types";
import { createRegistry } from "./providers";
@@ -59,15 +48,6 @@ export default class PennyPlugin extends Plugin {
/** Status bar widget instance */
statusBar: PennyStatusBar | null = null;
/** Status bar container element, for show/hide */
private statusBarEl: HTMLElement | null = null;
/** Ribbon icon element, for removal on toggle */
private ribbonIconEl: HTMLElement | null = null;
/** Context menu event reference, for removal on toggle */
private contextMenuRef: ReturnType<typeof this.app.workspace.on> | null = null;
/** Reference to the file-modify event, for cleanup */
private saveEventRef: ReturnType<typeof this.app.vault.on> | null = null;
@@ -104,8 +84,14 @@ export default class PennyPlugin extends Plugin {
// Register all commands
registerCommands(this);
// Set up togglable UI elements
this.setupUI();
// Add status bar item
const statusBarEl = this.addStatusBarItem();
this.statusBar = new PennyStatusBar(statusBarEl);
// Add ribbon icon -- click to process the active chapter
this.addRibbonIcon("pen-tool", "PENNY: Process this chapter", () => {
executeCommand(this.app, "penny:process-chapter");
});
// Update status bar when the active file changes
this.registerEvent(
@@ -121,12 +107,16 @@ export default class PennyPlugin extends Plugin {
// Initial status bar update
const activeFile = this.app.workspace.getActiveFile();
if (activeFile) {
this.statusBar?.update(activeFile, this);
this.statusBar.update(activeFile, this);
}
// First-run setup wizard
if (!this.settings.setupComplete) {
new SetupWizardModal(this).open();
// First-run notice if no provider is configured.
// ollamaEndpoint always has a default value, so only check the API key.
if (!this.settings.anthropicApiKey) {
new Notice(
"PENNY loaded. Configure an LLM provider in Settings > PENNY to get started.",
8000
);
}
}
@@ -157,9 +147,6 @@ export default class PennyPlugin extends Plugin {
// Re-configure the save hook in case autoProcessOnSave changed
this.setupSaveHook();
// Re-configure UI elements in case toggles changed
this.setupUI();
}
/**
@@ -208,104 +195,6 @@ export default class PennyPlugin extends Plugin {
});
}
/**
* Set up or tear down UI elements (ribbon, context menu, status bar)
* based on the current settings. Safe to call repeatedly.
*/
private setupUI(): void {
// --- Ribbon icon ---
if (this.settings.showRibbonIcon) {
if (!this.ribbonIconEl) {
this.ribbonIconEl = this.addRibbonIcon("pen-tool", "PENNY", (evt) => {
this.showRibbonMenu(evt);
});
}
} else {
if (this.ribbonIconEl) {
this.ribbonIconEl.remove();
this.ribbonIconEl = null;
}
}
// --- Context menu ---
if (this.settings.showContextMenu) {
if (!this.contextMenuRef) {
this.contextMenuRef = this.app.workspace.on("editor-menu", (menu) => {
menu.addSeparator();
menu.addItem((item) => {
item.setTitle("PENNY: Process this chapter").setIcon("pen-tool")
.onClick(() => executeCommand(this.app, "penny:process-chapter"));
});
menu.addItem((item) => {
item.setTitle("PENNY: Dry run").setIcon("eye")
.onClick(() => executeCommand(this.app, "penny:dry-run"));
});
menu.addItem((item) => {
item.setTitle("PENNY: Show status").setIcon("info")
.onClick(() => executeCommand(this.app, "penny:status"));
});
menu.addItem((item) => {
item.setTitle("PENNY: Do research").setIcon("search")
.onClick(() => executeCommand(this.app, "penny:research"));
});
});
this.registerEvent(this.contextMenuRef);
}
} else {
if (this.contextMenuRef) {
this.app.workspace.offref(this.contextMenuRef);
this.contextMenuRef = null;
}
}
// --- Status bar ---
if (this.settings.showStatusBar) {
if (!this.statusBarEl) {
this.statusBarEl = this.addStatusBarItem();
this.statusBar = new PennyStatusBar(this.statusBarEl);
}
} else {
if (this.statusBarEl) {
this.statusBarEl.remove();
this.statusBarEl = null;
this.statusBar = null;
}
}
}
/**
* Show the ribbon dropdown menu with all PENNY commands.
*/
private showRibbonMenu(evt: MouseEvent): void {
const menu = new Menu();
menu.addItem((item) => item.setTitle("Process this chapter").setIcon("zap")
.onClick(() => executeCommand(this.app, "penny:process-chapter")));
menu.addItem((item) => item.setTitle("Process all chapters").setIcon("layers")
.onClick(() => executeCommand(this.app, "penny:process-all")));
menu.addItem((item) => item.setTitle("Dry run").setIcon("eye")
.onClick(() => executeCommand(this.app, "penny:dry-run")));
menu.addSeparator();
menu.addItem((item) => item.setTitle("Do research").setIcon("search")
.onClick(() => executeCommand(this.app, "penny:research")));
menu.addItem((item) => item.setTitle("Show status").setIcon("info")
.onClick(() => executeCommand(this.app, "penny:status")));
menu.addSeparator();
menu.addItem((item) => item.setTitle("New chapter").setIcon("file-plus")
.onClick(() => executeCommand(this.app, "penny:new-chapter")));
menu.addItem((item) => item.setTitle("New character").setIcon("user-plus")
.onClick(() => executeCommand(this.app, "penny:new-character")));
menu.addItem((item) => item.setTitle("Initialize project").setIcon("folder-plus")
.onClick(() => executeCommand(this.app, "penny:init")));
menu.addSeparator();
menu.addItem((item) => item.setTitle("Migrate chapters").setIcon("folder-input")
.onClick(() => executeCommand(this.app, "penny:migrate")));
menu.addItem((item) => item.setTitle("Git commit").setIcon("git-commit-horizontal")
.onClick(() => executeCommand(this.app, "penny:git-commit")));
menu.addItem((item) => item.setTitle("Git push").setIcon("upload")
.onClick(() => executeCommand(this.app, "penny:git-push")));
menu.showAtMouseEvent(evt);
}
/**
* Check if a file matches the chapter file pattern.
* Uses simple glob matching to avoid ReDoS.
+4 -22
View File
@@ -1,14 +1,7 @@
/**
* PENNY - Chapter Migration (pure logic)
*
* Logic for converting flat chapter files (`04-drafts/book-1/ch-01.md`)
* to versioned folder structure (`04-drafts/book-1/ch-01/ch-01.v1.md`
* with `.version` and `.state.json` sidecar files).
*
* Called by commands.ts when the user runs "Migrate chapters". The actual
* file I/O (create folder, move file, delete original) happens in commands.ts;
* this module only provides planning and content generation.
*
* Logic for converting flat chapter files to versioned folders.
* Pure functions -- no Obsidian API dependencies.
*/
@@ -16,7 +9,6 @@ import type { VersionState } from "./types";
/**
* A migration plan for a single chapter file.
* Describes source and target paths without performing any I/O.
*/
export interface MigrationPlan {
/** Original flat file path, e.g. `04-drafts/book-1/ch-01.md` */
@@ -40,9 +32,6 @@ export interface MigrationPlan {
* Flat chapter files match `ch-NN.md` (not `ch-NN.vN.md` which are already
* versioned). Paths that look like they are already inside a chapter folder
* are marked as `alreadyMigrated`.
*
* @param files - All file paths in the drafts tree
* @returns A MigrationPlan for each flat chapter file found
*/
export function planMigration(files: string[]): MigrationPlan[] {
const plans: MigrationPlan[] = [];
@@ -85,28 +74,21 @@ export function planMigration(files: string[]): MigrationPlan[] {
/**
* Generate the v1 content from the original file.
* The content stays the same -- this is a straight copy (identity function).
*
* @param originalContent - Raw content of the flat chapter file
* @returns The same content, unchanged
* The content stays the same -- this is a straight copy.
*/
export function generateV1Content(originalContent: string): string {
return originalContent;
}
/**
* Generate the initial `.version` file content.
*
* @returns The string "1" (first version)
* Generate the initial .version file content.
*/
export function generateVersionFile(): string {
return "1";
}
/**
* Generate the initial `.state.json` file content.
*
* @returns Pretty-printed JSON for a fresh VersionState (version 1, no processed annotations)
* Generate the initial .state.json file content.
*/
export function generateStateJson(): string {
const initialState: VersionState = {
+24 -158
View File
@@ -1,20 +1,7 @@
/**
* PENNY - Annotation Parser
*
* Extracts editorial annotations from markdown chapter files.
* Called by pipeline.ts (and commands.ts for dry-run / status).
* Returns an array of AnnotatedSection objects sorted by line position.
*
* Supports two annotation forms:
* - Single-line: `%% TAG: instruction %%`
* - Multi-line: `%% TAG: instruction text\ncontinued...\n%%`
*
* Scope resolution determines WHAT each annotation targets:
* - Block: annotation after a `}}` line -> targets everything inside `{{ ... }}`
* - Inline: annotation shares a line with prose -> targets that line
* - Paragraph: annotation on its own line -> targets the paragraph above
* - Section: annotation below a heading -> targets everything to the next heading
*
* Parses `%% TAG: instruction %%` annotations from markdown text.
* Pure function -- no Obsidian API dependencies.
*/
@@ -22,47 +9,26 @@ import {
type AnnotatedSection,
type AnnotationTag,
ACTIONABLE_TAGS,
PASSTHROUGH_TAGS,
ALL_TAGS,
} from "./types";
/** Single-line annotation: `%% TAG: instruction %%` on one line. Captures tag and instruction. */
/** Non-global regex pattern for matching annotations. A new RegExp is created per use. */
const ANNOTATION_PATTERN = /%%\s*([A-Z]+)\s*:\s*(.*?)\s*%%/;
/**
* Multi-line annotation opening: `%% TAG: instruction text...` (no closing %% on same line).
* The instruction may continue across subsequent lines until a closing %% is found.
*/
const MULTILINE_OPEN = /%%\s*([A-Z]+)\s*:\s*(.*)/;
/** Multi-line annotation closing: captures any text before the closing %%. */
const MULTILINE_CLOSE = /(.*)%%/;
/**
* Strip all annotation markers from a string (single-line and multi-line).
* Used when building originalText so the passage content is clean prose.
*
* @param text - Raw line or block that may contain annotation markers
* @returns The text with all `%% ... %%` patterns removed
* Strip all annotation markers from a string.
*/
function stripAnnotations(text: string): string {
// Strip single-line annotations first
let result = text.replace(new RegExp(ANNOTATION_PATTERN.source, "g"), "");
// Strip multi-line annotation fragments (lines that are part of %% ... %% blocks)
result = result.replace(/%%[\s\S]*?%%/g, "");
return result;
return text.replace(new RegExp(ANNOTATION_PATTERN.source, "g"), "");
}
/**
* Generate a deterministic hash from the annotation's identity fields.
*
* Used for idempotency: the versioner tracks processed hashes so the same
* annotation is never sent to the LLM twice. Uses a cyrb53 variant rather
* than crypto APIs because it must be a pure function that works in both
* Node (tests) and Obsidian's Electron renderer without dependencies.
*
* @param tag - The annotation tag (e.g. "REWRITE")
* @param instruction - The author's instruction text
* @param originalText - The prose passage the annotation targets
* @returns A 12-character hex string (least-significant digits for max entropy)
* Uses a simple hash (cyrb53 variant) since we need pure-function behavior
* that runs in both Node and Obsidian's Electron renderer without crypto
* dependencies. Hex-encoded, truncated to 12 chars to match the spec's
* example format `"a1b2c3d4e5f6"`.
*/
function hashAnnotation(tag: string, instruction: string, originalText: string): string {
const input = `${tag}|${instruction}|${originalText}`;
@@ -82,12 +48,10 @@ function hashAnnotation(tag: string, instruction: string, originalText: string):
return n.toString(16).padStart(12, "0").slice(-12);
}
/** Type guard: true if the tag string is a recognized annotation tag. */
function isValidTag(tag: string): tag is AnnotationTag {
return (ALL_TAGS as readonly string[]).includes(tag);
}
/** True if the tag triggers LLM processing (not a passthrough tag). */
function isActionableTag(tag: string): boolean {
return (ACTIONABLE_TAGS as readonly string[]).includes(tag);
}
@@ -108,39 +72,21 @@ function isBlank(line: string): boolean {
return line.trim().length === 0;
}
/** True when a line is a block-scope opening marker `{{` (with optional whitespace). */
function isBlockOpen(line: string): boolean {
return line.trim() === "{{";
}
/** True when a line is a block-scope closing marker `}}` (with optional whitespace). */
function isBlockClose(line: string): boolean {
return line.trim() === "}}";
}
/**
* Determine the scope, lineStart, and lineEnd for an annotation found at
* `annotationLineIndex` in the array of `lines`.
*
* Resolution logic (checked in order):
* 1. If the annotation shares its line with non-annotation text -> "inline"
* 2. If the nearest non-blank line above is `}}` -> "block" (find matching `{{`)
* 3. If the nearest non-blank line above is a heading -> "section" (to next same-level heading)
* 4. Otherwise -> "paragraph" (the paragraph block above the annotation)
*
* @param lines - All lines of the chapter file
* @param annotationLineIndex - Zero-based index of the line containing the annotation
* @returns Scope type plus the inclusive [lineStart, lineEnd] range
*/
function resolveScope(
lines: string[],
annotationLineIndex: number,
): { scope: "inline" | "paragraph" | "section" | "block"; lineStart: number; lineEnd: number } {
): { scope: "inline" | "paragraph" | "section"; lineStart: number; lineEnd: number } {
const annoLine = lines[annotationLineIndex];
// --- Check: is the annotation inline (shares a line with other text)? ---
const stripped = stripAnnotations(annoLine).trim();
if (stripped.length > 0) {
// Inline: the annotation sits within a paragraph line.
// Scope = that single line.
return { scope: "inline", lineStart: annotationLineIndex, lineEnd: annotationLineIndex };
}
@@ -152,24 +98,10 @@ function resolveScope(
above--;
}
// --- Check: is the line above a `}}` block close marker? ---
if (above >= 0 && isBlockClose(lines[above])) {
const closeIdx = above;
// Search upward for the matching `{{`
let openIdx = closeIdx - 1;
while (openIdx >= 0) {
if (isBlockOpen(lines[openIdx])) break;
openIdx--;
}
if (openIdx >= 0) {
// Block scope: everything between {{ and }} (exclusive of the markers)
return { scope: "block", lineStart: openIdx + 1, lineEnd: closeIdx - 1 };
}
// No matching {{ found -- fall through to other scope types
}
// --- Check: is the line above a heading? ---
if (above >= 0 && isHeading(lines[above])) {
// Section scope: everything from the heading to the next heading of equal
// or higher level (or EOF).
const level = headingLevel(lines[above]);
let end = annotationLineIndex + 1;
while (end < lines.length) {
@@ -177,31 +109,30 @@ function resolveScope(
if (hl > 0 && hl <= level) break;
end++;
}
// lineEnd is the last line before the next heading (or last line of file).
return { scope: "section", lineStart: above, lineEnd: end - 1 };
}
// --- Otherwise: paragraph scope (paragraph immediately above). ---
// Walk upward from `above` to find the start of that paragraph (first blank
// line or start of file).
if (above < 0) {
// Nothing above -- degenerate; point at annotation itself.
return { scope: "paragraph", lineStart: annotationLineIndex, lineEnd: annotationLineIndex };
}
let paraStart = above;
while (paraStart > 0 && !isBlank(lines[paraStart - 1]) && !isHeading(lines[paraStart - 1]) && !lines[paraStart - 1].trim().startsWith("%% REVISED(")) {
while (paraStart > 0 && !isBlank(lines[paraStart - 1]) && !isHeading(lines[paraStart - 1])) {
paraStart--;
}
return { scope: "paragraph", lineStart: paraStart, lineEnd: annotationLineIndex };
}
/**
* Parse all annotations from a markdown chapter string.
* Parse all annotations from a markdown string.
*
* Scans every line for single-line and multi-line annotation patterns.
* For each valid annotation found, resolves its scope (inline, paragraph,
* or section) and extracts the targeted prose passage.
*
* @param content - The full markdown content of a chapter file
* @returns Array of AnnotatedSection objects sorted by lineStart ascending.
* Malformed annotations (unrecognized tags, empty instruction) are
* silently skipped.
* Returns an array of `AnnotatedSection` objects sorted by `lineStart`
* ascending. Malformed annotations (unrecognised tags, missing instruction)
* are silently skipped.
*/
export function parseAnnotations(content: string): AnnotatedSection[] {
const lines = content.split("\n");
@@ -209,13 +140,10 @@ export function parseAnnotations(content: string): AnnotatedSection[] {
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
// Try single-line annotations first
// Create a fresh global regex for each line to avoid shared state issues
const lineRegex = new RegExp(ANNOTATION_PATTERN.source, "g");
let foundSingleLine = false;
for (const match of line.matchAll(lineRegex)) {
foundSingleLine = true;
const tag = match[1];
const instruction = match[2].trim();
@@ -259,68 +187,6 @@ export function parseAnnotations(content: string): AnnotatedSection[] {
hash,
});
}
// If no single-line match, check for multi-line annotation opening
if (!foundSingleLine) {
const openMatch = line.match(MULTILINE_OPEN);
if (openMatch && !line.includes("%%", line.indexOf("%%") + 2)) {
// Found opening %% TAG: ... without closing %% on this line
const tag = openMatch[1];
const instructionParts = [openMatch[2].trim()];
let closeLine = i;
// Scan forward for closing %%
for (let j = i + 1; j < lines.length && j < i + 20; j++) {
const cl = lines[j];
const closeMatch = cl.match(MULTILINE_CLOSE);
if (closeMatch) {
instructionParts.push(closeMatch[1].trim());
closeLine = j;
break;
} else {
instructionParts.push(cl.trim());
}
}
const instruction = instructionParts.join(" ").trim();
if (isValidTag(tag) && instruction.length > 0) {
// Use the line AFTER the closing %% as the annotation target
const annotationLineIndex = closeLine;
const { scope, lineStart, lineEnd } = resolveScope(lines, annotationLineIndex);
let originalText: string;
if (scope === "inline") {
originalText = stripAnnotations(lines[annotationLineIndex]).trim();
} else {
const passageLines: string[] = [];
for (let j = lineStart; j <= lineEnd; j++) {
const strippedLine = stripAnnotations(lines[j]).trim();
if (strippedLine.length > 0 || isBlank(lines[j])) {
passageLines.push(stripAnnotations(lines[j]).trimEnd());
}
}
originalText = passageLines.join("\n").trim();
}
const actionable = isActionableTag(tag);
const hash = hashAnnotation(tag, instruction, originalText);
results.push({
tag: tag as AnnotationTag,
instruction,
originalText,
lineStart,
lineEnd,
scope,
actionable,
hash,
});
// Skip past the multi-line annotation
i = closeLine;
}
}
}
}
// Sort by lineStart ascending (stable).
+12 -229
View File
@@ -1,23 +1,10 @@
/**
* PENNY - Core Processing Pipeline (pure logic)
*
* Orchestrates the full annotation-processing workflow for a single chapter:
* 1. Parse annotations from the chapter markdown
* 2. Filter to only new (unprocessed) actionable annotations
* 3. For each annotation: assemble context, build prompt, call the LLM
* 4. Assemble the new chapter version from all revisions
* 5. Update frontmatter with agent-managed fields
* 6. Update version state for idempotency
* 7. Run voice compliance checks
* 8. Generate review notes and activity log entries
*
* Extracted from commands.ts for testability. This module has NO Obsidian
* dependencies. It takes pre-read content and an injected provider lookup
* (dependency injection), runs the pipeline, and returns results that
* commands.ts writes back to the vault.
*
* Supports cancellation via the isCancelled callback and emits real-time
* progress events for the progress modal UI.
* dependencies. It takes pre-read content and an injected provider lookup,
* runs the full annotation processing pipeline, and returns results that
* the command layer writes back to the vault.
*/
import type { AnnotatedSection, AnnotationChange, ReviewFlag, PennySettings } from "./types";
@@ -32,15 +19,13 @@ import { assembleNewVersion } from "./assembler";
import { readVersion, nextVersion, readState, updateState, shouldProcess, serializeState } from "./versioner";
import { parseFrontmatter, serializeFrontmatter, updateAgentFields, countProseWords, detectCharacters } from "./frontmatter";
import { generateReview } from "./reviewer";
import { createLogEntry, formatLogEntry, pennyLog } from "./logger";
import { createLogEntry, formatLogEntry } from "./logger";
import { checkVoiceCompliance } from "./voice-check";
import { getRoute } from "./providers/router";
/**
* A minimal provider interface for the pipeline. Matches the subset of
* LLMService that the pipeline actually uses (complete() + optional
* tokenMultiplier). This keeps the pipeline decoupled from the full
* provider implementation.
* LLMService that the pipeline actually uses.
*/
export interface PipelineProvider {
complete(request: CompletionRequest): Promise<CompletionResponse>;
@@ -48,70 +33,16 @@ export interface PipelineProvider {
tokenMultiplier?: number;
}
/** Progress events emitted during pipeline execution. Consumed by PennyProgressModal. */
export interface ProgressEvent {
type:
| "start"
| "annotation-start"
| "annotation-done"
| "annotation-error"
| "assembling"
| "complete"
| "cancelled"
| "token"
| "retry";
total?: number;
current?: number;
tag?: string;
line?: number;
provider?: string;
model?: string;
wordCount?: number;
error?: string;
message?: string;
/** Streaming token text (for type: "token"). */
text?: string;
/** Upcoming attempt number (1-based) for type: "retry". */
attempt?: number;
/** Milliseconds the pipeline will sleep before the upcoming attempt. */
waitMs?: number;
/** Short cause label (e.g. "HTTP 429", "TimeoutError"). */
reason?: string;
}
/**
* Everything the pipeline needs to process a chapter.
* All file content is pre-read by commands.ts; the pipeline never touches the vault.
*/
export interface PipelineInput {
/** Full markdown content of the chapter file. */
content: string;
/** Raw text from the `.version` file (may be empty for new chapters). */
versionContent: string;
/** Raw JSON from the `.state.json` file (may be empty for new chapters). */
stateContent: string;
/** Pre-read context files (voice tests, style guide, outlines, etc.). */
contextFiles: ContextFiles;
/** Plugin settings (possibly with per-project overrides merged in). */
settings: PennySettings;
/** Chapter identifier (e.g. "ch-05"). */
chapterId: string;
/** Book identifier (e.g. "book-1"). */
bookId: string;
/** Provider lookup: given a provider name, returns an object with a complete() method, or undefined. */
/** Provider lookup: given a provider name, returns an object with a complete() method, or undefined */
getProvider: (name: string) => PipelineProvider | undefined;
/** Optional callback for real-time progress updates (drives the progress modal). */
onProgress?: (event: ProgressEvent) => void;
/** Optional cancellation check; return true to abort the pipeline loop. */
isCancelled?: () => boolean;
/**
* Optional AbortSignal. When aborted, the in-flight provider call is torn
* down immediately (not just between annotations), and the pipeline emits
* a "cancelled" event and resolves with null.
*/
signal?: AbortSignal;
/** Pre-parsed annotations to avoid redundant parseAnnotations call (optimization). */
preParsedAnnotations?: AnnotatedSection[];
}
export interface PipelineResult {
@@ -139,11 +70,7 @@ export interface PipelineResult {
/**
* Build the RouteConfig from plugin settings.
* When useSameModelForAll is true, all tiers use the standard route,
* giving a uniform model experience regardless of annotation complexity.
*
* @param s - Current plugin settings
* @returns A RouteConfig with light/standard/heavy tier assignments
* When useSameModelForAll is true, all tiers use the standard route.
*/
export function buildRouteConfig(s: PennySettings): RouteConfig {
if (s.useSameModelForAll) {
@@ -164,20 +91,14 @@ export function buildRouteConfig(s: PennySettings): RouteConfig {
* Run the core processing pipeline on chapter content.
*
* Pure logic except for LLM calls (injected via getProvider).
* Processes each actionable annotation sequentially, building up
* revisions that are then assembled into a new chapter version.
*
* @param input - All pre-read content, settings, and injected dependencies
* @returns Pipeline result with new content, state, review, and log data,
* or null if there are no new annotations to process
* Returns null if there are no new annotations to process.
*/
export async function runPipeline(input: PipelineInput): Promise<PipelineResult | null> {
const startTime = Date.now();
const { content, settings, chapterId, bookId } = input;
const onProgress = input.onProgress;
// (a) Parse annotations (use pre-parsed if provided to avoid redundant work)
const allAnnotations = input.preParsedAnnotations ?? parseAnnotations(content);
// (a) Parse annotations
const allAnnotations = parseAnnotations(content);
// (b) Read version and state
const currentVersion = readVersion(input.versionContent);
@@ -193,13 +114,6 @@ export async function runPipeline(input: PipelineInput): Promise<PipelineResult
// Build route config
const routeConfig = buildRouteConfig(settings);
// Emit start event
onProgress?.({
type: "start",
total: toProcess.length,
message: `Processing ${toProcess.length} annotation${toProcess.length === 1 ? "" : "s"}...`,
});
// Count passthrough annotations
const passthroughCount = allAnnotations.filter((a) => !a.actionable).length;
@@ -212,41 +126,11 @@ export async function runPipeline(input: PipelineInput): Promise<PipelineResult
const flags: ReviewFlag[] = [];
const processedTags: string[] = [];
let hadErrors = false;
let completedCount = 0;
// Unified cancellation check: either the legacy isCancelled callback OR
// an AbortSignal being aborted counts as "cancel requested".
const isCancelled = (): boolean =>
(input.isCancelled?.() ?? false) || (input.signal?.aborted ?? false);
for (let i = 0; i < toProcess.length; i++) {
// Check for cancellation before each annotation
if (isCancelled()) {
onProgress?.({
type: "cancelled",
current: completedCount,
total: toProcess.length,
message: `Cancelled. Processed ${completedCount} of ${toProcess.length} annotation${toProcess.length === 1 ? "" : "s"}.`,
});
return null;
}
const annotation = toProcess[i];
for (const annotation of toProcess) {
const route = getRoute(annotation.tag, routeConfig);
const provider = input.getProvider(route.provider);
// Emit annotation-start
onProgress?.({
type: "annotation-start",
current: i + 1,
total: toProcess.length,
tag: annotation.tag,
line: annotation.lineStart,
provider: route.provider,
model: route.model,
message: `[${i + 1}/${toProcess.length}] ${annotation.tag} line ${annotation.lineStart} ... calling ${route.provider} ${route.model} ...`,
});
if (!provider) {
// Insert error marker and continue
const errorText = `%% AGENT-ERROR: Provider "${route.provider}" not available for ${annotation.tag} %%\n${annotation.originalText}`;
@@ -257,15 +141,6 @@ export async function runPipeline(input: PipelineInput): Promise<PipelineResult
description: `Provider "${route.provider}" not found for [${annotation.tag}]`,
});
hadErrors = true;
onProgress?.({
type: "annotation-error",
current: i + 1,
total: toProcess.length,
tag: annotation.tag,
line: annotation.lineStart,
error: `Provider "${route.provider}" not available`,
message: `[${i + 1}/${toProcess.length}] ${annotation.tag} line ${annotation.lineStart} ... ERROR: Provider "${route.provider}" not available`,
});
continue;
}
@@ -292,7 +167,7 @@ export async function runPipeline(input: PipelineInput): Promise<PipelineResult
const tier: ComplexityTier = TAG_COMPLEXITY[annotation.tag] ?? "standard";
const useThinking = tier === "heavy" && route.provider === "anthropic";
// Call provider (with streaming token callback when progress is available)
// Call provider
const response = await callProvider(provider, {
systemPrompt: system,
userPrompt: user,
@@ -300,36 +175,9 @@ export async function runPipeline(input: PipelineInput): Promise<PipelineResult
maxTokens: settings.maxTokens ?? 16000,
apiKey: route.provider === "anthropic" ? settings.anthropicApiKey
: route.provider === "ollama" ? (settings.ollamaApiKey || undefined)
: route.provider === "google" ? settings.googleApiKey
: route.provider === "openai" ? settings.openaiApiKey
: undefined,
endpoint: route.provider === "ollama" ? settings.ollamaEndpoint : undefined,
useThinking,
signal: input.signal,
timeoutMs: settings.requestTimeoutMs,
maxRetries: settings.maxRetries ?? 3,
onRetry: (info) => {
pennyLog("info", settings.logLevel, "LLM retry", {
provider: route.provider,
model: route.model,
attempt: info.attempt,
waitMs: info.waitMs,
reason: info.reason,
});
onProgress?.({
type: "retry",
attempt: info.attempt,
waitMs: info.waitMs,
reason: info.reason,
provider: route.provider,
model: route.model,
current: i + 1,
total: toProcess.length,
});
},
onToken: onProgress ? (text: string) => {
onProgress({ type: "token", text, current: i + 1 });
} : undefined,
});
revisions.push({ annotation, revisedText: response.text });
@@ -356,30 +204,7 @@ export async function runPipeline(input: PipelineInput): Promise<PipelineResult
provider: route.provider,
model: route.model,
});
completedCount++;
onProgress?.({
type: "annotation-done",
current: i + 1,
total: toProcess.length,
tag: annotation.tag,
line: annotation.lineStart,
wordCount: revisedWords,
message: `[${i + 1}/${toProcess.length}] ${annotation.tag} line ${annotation.lineStart} ... done (${revisedWords} words)`,
});
} catch (err) {
// AbortError means the user cancelled -- don't treat it as an API failure,
// don't insert an error marker, don't create a version. Just stop.
if (err instanceof Error && err.name === "AbortError") {
onProgress?.({
type: "cancelled",
current: completedCount,
total: toProcess.length,
message: `Cancelled during [${i + 1}/${toProcess.length}] ${annotation.tag}. Processed ${completedCount}.`,
});
return null;
}
const errMsg = err instanceof Error ? err.message : String(err);
const errorText = `%% AGENT-ERROR(${annotation.tag}): ${errMsg} %%\n${annotation.originalText}`;
revisions.push({ annotation, revisedText: errorText });
@@ -389,48 +214,11 @@ export async function runPipeline(input: PipelineInput): Promise<PipelineResult
description: `API error for [${annotation.tag}]: ${errMsg}`,
});
hadErrors = true;
onProgress?.({
type: "annotation-error",
current: i + 1,
total: toProcess.length,
tag: annotation.tag,
line: annotation.lineStart,
error: errMsg,
message: `[${i + 1}/${toProcess.length}] ${annotation.tag} line ${annotation.lineStart} ... ERROR: ${errMsg}`,
});
}
}
// Check if cancelled before proceeding to assembly
if (isCancelled()) {
onProgress?.({
type: "cancelled",
current: completedCount,
total: toProcess.length,
message: `Cancelled. Processed ${completedCount} of ${toProcess.length}.`,
});
return null;
}
// Don't create a version if no annotations were successfully processed
if (completedCount === 0) {
onProgress?.({
type: "complete",
message: hadErrors
? `All ${toProcess.length} annotation(s) failed. No version created.`
: `No annotations processed. No version created.`,
});
return null;
}
// (f) Assemble new version
const newVer = nextVersion(currentVersion);
onProgress?.({
type: "assembling",
message: `Assembling v${newVer}...`,
});
const assemblyResult = assembleNewVersion(content, revisions, newVer);
const assembled = assemblyResult.content;
@@ -505,11 +293,6 @@ export async function runPipeline(input: PipelineInput): Promise<PipelineResult
});
const logLine = formatLogEntry(logEntry);
onProgress?.({
type: "complete",
message: `Done. ${changes.length} annotation${changes.length === 1 ? "" : "s"} processed, v${newVer} created.`,
});
return {
newContent,
newVersion: newVer,
-180
View File
@@ -1,180 +0,0 @@
/**
* PENNY - Progress Modal State Machine
*
* Pure state machine that drives PennyProgressModal. Contains no DOM code
* and no dependency on obsidian, so it's directly unit-testable.
*
* Why a state machine:
* The previous modal tracked state via a `cancelled` boolean AND by inspecting
* button text (`if (btn.textContent === "Cancel") ...`). The two representations
* drifted: clicking the button labelled "Close" after completion ran the Cancel
* handler because the handler was still bound -- the text had changed, the
* wiring hadn't. Explicit states + per-state button visibility rules make
* "Cancel" and "Close" structurally distinct actions that can never collide.
*
* State model:
* starting -- modal opened, no events yet
* processing -- pipeline is working through annotations
* assembling -- pipeline is assembling the final version
* done -- complete event received
* cancelled -- user clicked Cancel OR pipeline emitted "cancelled"
* error -- a fatal error occurred
*
* Terminal states (done/cancelled/error) show only a Close button.
* Active states (starting/processing/assembling) show Cancel and Minimize.
* Cancel is never shown in terminal states; Close is never shown in active states.
* This is enforced by `view()` and cannot be bypassed by button-text mutation.
*/
import type { ProgressEvent } from "./pipeline";
export type ModalState =
| "starting"
| "processing"
| "assembling"
| "done"
| "cancelled"
| "error";
export interface ModalView {
/** Text to display in the header. Never "Cancelling..." -- cancellation is instantaneous. */
header: string;
/** Whether the elapsed-time timer should be ticking. False in terminal states. */
timerRunning: boolean;
/** Cancel button is shown only in active states. */
cancelButtonVisible: boolean;
/** Close button is shown only in terminal states. */
closeButtonVisible: boolean;
/** Minimize is available during active processing, hidden in terminal states and during assembling. */
minimizeButtonVisible: boolean;
/** Streaming preview (the live-token element) is visible during active annotation processing. */
streamPreviewVisible: boolean;
/** Quote panel is visible throughout active processing and hidden in terminal states. */
quotePanelVisible: boolean;
}
export interface StateMachineCallbacks {
/** Called exactly once when the user first requests cancellation. */
onAbort?: () => void;
}
const TERMINAL_STATES: ReadonlySet<ModalState> = new Set(["done", "cancelled", "error"]);
export class ProgressModalStateMachine {
private _state: ModalState = "starting";
private _abortFired = false;
private _latestMessage: string | null = null;
private _errorMessage: string | null = null;
constructor(private callbacks: StateMachineCallbacks = {}) {}
get state(): ModalState {
return this._state;
}
/**
* Drive state transitions from pipeline events. Events that don't require
* a state change (token, annotation-done, annotation-error) are absorbed
* silently -- the DOM shell handles their rendering.
*/
handleEvent(event: ProgressEvent): void {
if (event.message) {
this._latestMessage = event.message;
}
switch (event.type) {
case "start":
// Stay in starting; the first annotation-start moves us to processing.
break;
case "annotation-start":
if (!TERMINAL_STATES.has(this._state)) this._state = "processing";
break;
case "assembling":
if (!TERMINAL_STATES.has(this._state)) this._state = "assembling";
break;
case "complete":
// complete overrides any non-cancelled state. We honour prior cancel/error.
if (this._state !== "cancelled" && this._state !== "error") {
this._state = "done";
}
break;
case "cancelled":
this._state = "cancelled";
break;
// token / annotation-done / annotation-error don't transition state
case "token":
case "annotation-done":
case "annotation-error":
break;
}
}
/**
* User clicked Cancel. Fires onAbort exactly once across the run (even if
* Cancel is clicked multiple times in quick succession), and transitions
* to "cancelled". No-op in terminal states.
*/
requestCancel(): void {
if (TERMINAL_STATES.has(this._state)) return;
if (!this._abortFired) {
this._abortFired = true;
try {
this.callbacks.onAbort?.();
} catch {
// onAbort throwing must not leave the state machine in a weird spot.
}
}
this._state = "cancelled";
}
/** Returns true iff closing the modal is currently allowed (terminal states only). */
canClose(): boolean {
return TERMINAL_STATES.has(this._state);
}
/** Explicitly set the modal into the error terminal state with a message. */
setErrorState(message: string): void {
this._state = "error";
this._errorMessage = message;
}
/** Derive the UI view from the current state. Pure -- no side effects. */
view(): ModalView {
const state = this._state;
const timerRunning = !TERMINAL_STATES.has(state);
const inTerminal = TERMINAL_STATES.has(state);
return {
header: this.headerFor(state),
timerRunning,
cancelButtonVisible: !inTerminal,
closeButtonVisible: inTerminal,
// Minimize is only meaningful while actively processing annotations.
// During assembling it's confusing (we're not doing the slow part anymore).
minimizeButtonVisible: state === "starting" || state === "processing",
streamPreviewVisible: state === "processing",
quotePanelVisible: !inTerminal,
};
}
private headerFor(state: ModalState): string {
switch (state) {
case "starting":
return this._latestMessage ?? "Starting...";
case "processing":
return this._latestMessage ?? "Processing...";
case "assembling":
return this._latestMessage ?? "Assembling...";
case "done":
return this._latestMessage ?? "Complete";
case "cancelled":
return this._latestMessage
? `Cancelled. ${this._latestMessage}`
: "Cancelled.";
case "error":
return this._errorMessage
? `Error: ${this._errorMessage}`
: "Error.";
}
}
}
-333
View File
@@ -1,333 +0,0 @@
/**
* PENNY - Progress Modal
*
* Thin DOM shell around ProgressModalStateMachine. The state machine owns
* all non-trivial logic (transitions, button visibility, header text, timer
* start/stop); this file renders the current view and re-renders on state
* change.
*
* Lifecycle:
* - Created and opened by commands.ts before calling runPipeline()
* - Updated by the pipeline's onProgress callback with ProgressEvents
* - User can minimize (processing continues, status shown via Notices)
* - User can cancel (onAbort callback triggers the pipeline's AbortController)
* - Transitions to a terminal state on complete/cancelled/error event
*
* Button rule: Cancel and Close are structurally distinct elements. They are
* (re)created each time `renderButtons()` runs, so a click handler can never
* be attached to the wrong action. This is enforced by the state machine's
* view flags -- a terminal state has cancelButtonVisible=false unconditionally.
*/
import { Modal, App, Notice } from "obsidian";
import type { ProgressEvent } from "./pipeline";
import { ProgressModalStateMachine } from "./progress-modal-state";
import { pickRandomQuote, quoteAt } from "./quotes";
const QUOTE_ROTATION_MS = 30_000;
export interface PennyProgressModalOptions {
/** Invoked exactly once when the user clicks Cancel. Should trigger AbortController.abort(). */
onAbort?: () => void;
/** Whether to surface annotation milestones as Notices when minimized. */
showStatusNotices?: boolean;
}
export class PennyProgressModal extends Modal {
private stateMachine: ProgressModalStateMachine;
private minimized = false;
private showStatusNotices: boolean;
private onAbortCallback?: () => void;
// DOM handles
private logEl!: HTMLElement;
private headerEl!: HTMLElement;
private timerEl!: HTMLElement;
private buttonRowEl!: HTMLElement;
private quotePanelEl!: HTMLElement;
private quoteTextEl!: HTMLElement;
private quoteAuthorEl!: HTMLElement;
private streamEl: HTMLElement | null = null;
private streamPlaceholderActive = false;
// Timers
private timerInterval: ReturnType<typeof setInterval> | null = null;
private quoteInterval: ReturnType<typeof setInterval> | null = null;
private startTime = Date.now();
private quoteIndex = 0;
constructor(
app: App,
title: string,
showStatusNoticesOrOpts: boolean | PennyProgressModalOptions = true,
) {
super(app);
this.titleEl.setText(title);
// Back-compat: old callers passed a boolean for showStatusNotices.
if (typeof showStatusNoticesOrOpts === "boolean") {
this.showStatusNotices = showStatusNoticesOrOpts;
} else {
this.showStatusNotices = showStatusNoticesOrOpts.showStatusNotices ?? true;
this.onAbortCallback = showStatusNoticesOrOpts.onAbort;
}
this.stateMachine = new ProgressModalStateMachine({
onAbort: () => this.onAbortCallback?.(),
});
}
onOpen(): void {
const { contentEl } = this;
contentEl.empty();
contentEl.addClass("penny-progress-modal");
// Size the outer modal wrapper, not just the content. `min-width` on
// contentEl alone doesn't propagate up through Obsidian's `.modal` chain
// reliably -- the wrapper auto-sizes narrower than we want.
this.modalEl.addClass("penny-progress-modal-wrapper");
const headerRow = contentEl.createEl("div", { cls: "penny-progress-header-row" });
this.headerEl = headerRow.createEl("div", { cls: "penny-progress-header" });
this.timerEl = headerRow.createEl("div", { cls: "penny-progress-timer" });
this.logEl = contentEl.createEl("div", { cls: "penny-progress-log" });
// Quote panel (visible during active processing; hidden in terminal states)
this.quotePanelEl = contentEl.createEl("div", { cls: "penny-quote-panel" });
this.quotePanelEl.createEl("div", {
cls: "penny-quote-panel-label",
text: "while we wait together...",
});
this.quoteTextEl = this.quotePanelEl.createEl("div", { cls: "penny-quote-text" });
this.quoteAuthorEl = this.quotePanelEl.createEl("div", { cls: "penny-quote-author" });
this.seedQuote();
this.buttonRowEl = contentEl.createEl("div", { cls: "penny-progress-buttons" });
// Start the wall-clock timer and quote rotation
this.startTime = Date.now();
this.timerInterval = setInterval(() => this.renderTimer(), 1000);
this.quoteInterval = setInterval(() => this.rotateQuote(), QUOTE_ROTATION_MS);
this.render();
}
/** Whether the modal was minimized (processing continues in background). */
isMinimized(): boolean {
return this.minimized;
}
/** Called by processChapter to update the modal with a pipeline event. */
update(event: ProgressEvent): void {
// If minimized, forward milestone events to Notices and skip DOM work.
if (this.minimized) {
if (this.showStatusNotices && event.message) {
if (
event.type === "annotation-done" ||
event.type === "annotation-error" ||
event.type === "complete" ||
event.type === "cancelled"
) {
new Notice(`PENNY: ${event.message}`, event.type === "complete" ? 6000 : 3000);
}
}
this.stateMachine.handleEvent(event);
return;
}
// Handle content-level DOM updates (these don't change state)
this.renderEventContent(event);
// Drive state transitions (which may change buttons/header/timer)
this.stateMachine.handleEvent(event);
this.render();
}
/**
* Back-compat shim. Old commands.ts reads `modal.isCancelled()` to decide
* whether the pipeline ended via cancel-vs-no-annotations. New callers
* should rely on the pipeline's null return + the "cancelled" event type
* instead, but we preserve the method to avoid churning all call sites.
*/
isCancelled(): boolean {
return this.stateMachine.state === "cancelled";
}
/** Move the modal into an error terminal state (for fatal pipeline failures). */
setError(message: string): void {
this.stateMachine.setErrorState(message);
this.render();
}
onClose(): void {
if (this.timerInterval) {
clearInterval(this.timerInterval);
this.timerInterval = null;
}
if (this.quoteInterval) {
clearInterval(this.quoteInterval);
this.quoteInterval = null;
}
this.contentEl.empty();
}
// ---------- internal rendering ----------
private render(): void {
const view = this.stateMachine.view();
this.headerEl.setText(view.header);
// Timer rendering
if (!view.timerRunning && this.timerInterval) {
clearInterval(this.timerInterval);
this.timerInterval = null;
this.renderTimer(true);
}
// Quote panel visibility
this.quotePanelEl.style.display = view.quotePanelVisible ? "" : "none";
if (!view.quotePanelVisible && this.quoteInterval) {
clearInterval(this.quoteInterval);
this.quoteInterval = null;
}
this.renderButtons(view);
}
private renderEventContent(event: ProgressEvent): void {
switch (event.type) {
case "annotation-start": {
const line = this.logEl.createEl("div", {
cls: "penny-progress-line penny-progress-active",
});
line.dataset.index = String(event.current);
const spinner = line.createEl("span", { cls: "penny-inline-spinner" });
spinner.setText("");
line.createEl("span", { text: ` ${event.message ?? ""}` });
this.streamEl = this.logEl.createEl("div", { cls: "penny-stream-preview" });
this.streamEl.createEl("span", {
cls: "penny-waiting-text",
text: "Waiting for the model to start streaming...",
});
this.streamPlaceholderActive = true;
break;
}
case "token": {
if (this.streamEl && event.text) {
if (this.streamPlaceholderActive) {
this.streamEl.empty();
this.streamPlaceholderActive = false;
}
this.streamEl.appendText(event.text);
// Pin the preview to its own bottom so new tokens stay visible.
// The outer log does not scroll -- only this inner element does.
this.streamEl.scrollTop = this.streamEl.scrollHeight;
}
break;
}
case "retry": {
const line = this.logEl.querySelector(
`[data-index="${event.current}"]`,
) as HTMLElement | null;
if (line) {
const attempt = event.attempt ?? 0;
const waitSec = Math.round((event.waitMs ?? 0) / 1000);
const reason = event.reason ?? "transient error";
// Replace the line content. Spinner is dropped for now; it returns
// on the next annotation-start cycle. Acceptable trade-off for a
// transient state.
line.setText(
` Retrying after ${reason} (attempt ${attempt}, waiting ${waitSec}s)...`,
);
}
break;
}
case "annotation-done": {
if (this.streamEl) {
this.streamEl.remove();
this.streamEl = null;
this.streamPlaceholderActive = false;
}
const line = this.logEl.querySelector(
`[data-index="${event.current}"]`,
) as HTMLElement | null;
if (line) {
line.setText(event.message ?? "");
line.removeClass("penny-progress-active");
line.addClass("penny-progress-done");
}
break;
}
case "annotation-error": {
const line = this.logEl.querySelector(
`[data-index="${event.current}"]`,
) as HTMLElement | null;
if (line) {
line.setText(event.message ?? "");
line.removeClass("penny-progress-active");
line.addClass("penny-progress-error");
}
break;
}
// start / assembling / complete / cancelled handled entirely via render()
case "start":
case "assembling":
case "complete":
case "cancelled":
break;
}
}
private renderButtons(view: { cancelButtonVisible: boolean; closeButtonVisible: boolean; minimizeButtonVisible: boolean }): void {
// Re-create the button row from scratch every time the view changes. This
// is the core of the fix: listeners are attached per render, so a button
// element only ever carries the handler that matches its current label.
this.buttonRowEl.empty();
if (view.minimizeButtonVisible) {
const btn = this.buttonRowEl.createEl("button", { text: "Minimize" });
btn.addEventListener("click", () => {
this.minimized = true;
this.close();
new Notice("PENNY: Processing continues in background. Status updates in notices.", 4000);
});
}
if (view.cancelButtonVisible) {
const btn = this.buttonRowEl.createEl("button", { text: "Cancel" });
btn.addEventListener("click", () => {
this.stateMachine.requestCancel();
this.render();
});
}
if (view.closeButtonVisible) {
const btn = this.buttonRowEl.createEl("button", { text: "Close" });
btn.addEventListener("click", () => this.close());
}
}
private renderTimer(final = false): void {
const elapsed = Math.floor((Date.now() - this.startTime) / 1000);
const mins = Math.floor(elapsed / 60);
const secs = elapsed % 60;
const pretty = mins > 0 ? `${mins}m ${secs}s` : `${secs}s`;
this.timerEl.setText(final ? `Done in ${pretty}` : pretty);
}
private seedQuote(): void {
const q = pickRandomQuote();
this.quoteIndex = Math.floor(Math.random() * 1_000_000);
this.quoteTextEl.setText(`"${q.text}"`);
this.quoteAuthorEl.setText(`— ${q.author}`);
}
private rotateQuote(): void {
this.quoteIndex += 1;
const q = quoteAt(this.quoteIndex);
this.quoteTextEl.setText(`"${q.text}"`);
this.quoteAuthorEl.setText(`— ${q.author}`);
}
}
+9 -37
View File
@@ -3,25 +3,15 @@
*
* Reads PENNY.md files from the project directory tree to provide
* per-project setting overrides. Walks up from a chapter file's
* directory looking for the nearest PENNY.md (like how .gitignore
* resolution works).
* directory looking for the nearest PENNY.md.
*
* A PENNY.md file can contain two sections:
* - `## Project`: key-value pairs that override PennySettings fields
* (e.g. `drafts: my-drafts`, `context-budget: 500000`)
* - `## Voice Rules`: free-form text that overrides customVoiceRules
*
* Called by commands.ts before running the pipeline, so each book or
* series can have its own style guide paths, voice rules, etc.
* Parsed key-value pairs from the `## Project` section override
* global settings.
*/
import type { PennySettings } from "./types";
/**
* Map of PENNY.md key names (lowercase, kebab-case) to their corresponding
* PennySettings field names. Only these keys are recognized in the
* `## Project` section of a PENNY.md file.
*/
/** Map of PENNY.md keys to PennySettings field names. */
const KEY_MAP: Record<string, keyof PennySettings> = {
"drafts": "draftsFolder",
"style-guide": "styleGuide",
@@ -38,8 +28,7 @@ const KEY_MAP: Record<string, keyof PennySettings> = {
/**
* Minimal vault interface so this module has no direct Obsidian dependency.
* The real Obsidian Vault object satisfies this shape at runtime.
* In tests, a simple mock can be substituted.
* The real Vault satisfies this shape.
*/
export interface VaultReader {
adapter: {
@@ -50,11 +39,7 @@ export interface VaultReader {
/**
* Walk up from a file's directory looking for a PENNY.md file.
* Checks each parent directory up to the vault root.
*
* @param filePath - Path of the chapter file being processed
* @param vault - Vault reader for checking file existence and reading content
* @returns Parsed settings overrides, or null if no PENNY.md is found
* Returns the parsed overrides or null if no PENNY.md is found.
*/
export async function findProjectConfig(
filePath: string,
@@ -96,16 +81,8 @@ export async function findProjectConfig(
}
/**
* Parse a PENNY.md file into settings overrides.
*
* Extracts:
* - `## Project` section: simple `key: value` pairs mapped to PennySettings fields
* - `## Voice Rules` section: free-form text used as customVoiceRules
*
* HTML comments within the Voice Rules section are stripped.
*
* @param content - Full content of the PENNY.md file
* @returns Partial PennySettings with only the overridden fields
* Parse the `## Project` section from a PENNY.md file.
* Extracts simple `key: value` pairs and maps them to PennySettings fields.
*/
export function parseProjectConfig(content: string): Partial<PennySettings> {
const overrides: Partial<PennySettings> = {};
@@ -169,12 +146,7 @@ export function parseProjectConfig(content: string): Partial<PennySettings> {
voiceLines.push(line);
}
// Strip inline HTML comments that weren't caught by the multi-line filter
const cleaned = voiceLines.map(line =>
line.replace(/<!--[\s\S]*?-->/g, "").trim()
).filter(line => line.length > 0);
const voiceRulesText = cleaned.join("\n").trim();
const voiceRulesText = voiceLines.join("\n").trim();
if (voiceRulesText) {
overrides.customVoiceRules = voiceRulesText;
}
+32 -221
View File
@@ -1,19 +1,9 @@
/**
* PENNY - Anthropic Claude Provider
*
* Implements the LLMService interface for the Anthropic Messages API.
* Supports Claude Opus, Sonnet, and Haiku model families.
*
* Key features:
* - Adaptive thinking (extended thinking) for heavy-tier annotations
* on supported models (Opus 4.6, Sonnet 4.6)
* - Static model catalog (ANTHROPIC_MODELS) for offline settings display
* - Structured error handling with Anthropic-specific status codes
* - Response parsing that extracts text blocks from the content array
* (skipping thinking blocks when extended thinking is enabled)
*
* Implements LLMService for the Anthropic Messages API.
* Uses an injected HTTP function (Obsidian's requestUrl) so this module
* has no direct Obsidian API dependency and can be tested with a mock.
* has no direct Obsidian API dependency.
*/
import type {
@@ -24,12 +14,11 @@ import type {
ProviderSettings,
HttpFn,
} from "./service";
import { streamRequest, withTimeout, withRetry, HttpError } from "./node-stream";
const ANTHROPIC_API_URL = "https://api.anthropic.com/v1/messages";
const ANTHROPIC_VERSION = "2023-06-01";
/** Models that support the adaptive / extended thinking feature (Claude 4.6+). */
/** Models that support the extended thinking feature. */
const THINKING_CAPABLE = new Set(["claude-opus-4-6", "claude-sonnet-4-6"]);
/**
@@ -38,22 +27,19 @@ const THINKING_CAPABLE = new Set(["claude-opus-4-6", "claude-sonnet-4-6"]);
* Intentionally hardcoded for offline/static use. The settings dropdown also
* supports free-text input (via Ollama rows), so users are not locked to
* this list. Update manually when Anthropic ships new model aliases.
*
* Context windows current as of April 2026. Opus 4.6 and Sonnet 4.6 support
* 1M context in beta; Haiku 4.5 remains at 200k.
*/
export const ANTHROPIC_MODELS: ModelInfo[] = [
{
id: "claude-opus-4-6",
name: "Claude Opus 4.6",
contextWindow: 1000000,
contextWindow: 200000,
costPer1kInput: 0.015,
costPer1kOutput: 0.075,
},
{
id: "claude-sonnet-4-6",
name: "Claude Sonnet 4.6",
contextWindow: 1000000,
contextWindow: 200000,
costPer1kInput: 0.003,
costPer1kOutput: 0.015,
},
@@ -66,13 +52,12 @@ export const ANTHROPIC_MODELS: ModelInfo[] = [
},
];
/** Token estimation multiplier: ~1.33 tokens per word for Claude's tokenizer. */
/** Token estimation multiplier: ~1.33 tokens per word for Anthropic models. */
const TOKEN_MULTIPLIER = 1.33;
export class AnthropicProvider implements LLMService {
readonly name = "anthropic";
readonly requiresApiKey = true;
readonly tokenMultiplier = 1.33;
constructor(private httpFn: HttpFn) {}
@@ -86,55 +71,6 @@ export class AnthropicProvider implements LLMService {
throw new Error("Anthropic API key is required");
}
const maxRetries = request.maxRetries ?? 3;
// Track whether streaming has begun emitting tokens. Once any token has
// reached the user, a retry would re-emit them, so the retry helper
// skips further attempts via canStillRetry.
let started = false;
const wrappedRequest: CompletionRequest = request.onToken
? {
...request,
onToken: (text: string) => {
started = true;
request.onToken!(text);
},
}
: request;
return withRetry(() => this.doOneAttempt(wrappedRequest, apiKey), {
maxAttempts: maxRetries + 1,
canStillRetry: () => !started,
signal: request.signal,
onRetry: request.onRetry,
});
}
private async doOneAttempt(
request: CompletionRequest,
apiKey: string,
): Promise<CompletionResponse> {
// Try streaming when onToken is provided. Uses Node's https module under
// the hood (streamRequest) which bypasses Obsidian's Electron CSP entirely.
// The globalThis.fetch guard is kept as a "is this a web-capable env"
// signal and to let tests opt out of the streaming path.
//
// AbortError from a cancelled stream MUST propagate -- it's not a
// transport failure, it's the user clicking Cancel. Genuine API errors
// (401/429/etc) also propagate. Only true transport-unavailable errors
// fall through to the non-streaming httpFn path as a safety net.
if (request.onToken && typeof globalThis.fetch === "function") {
try {
return await this.completeStreaming(request, apiKey);
} catch (e) {
if (e instanceof Error && e.name === "AbortError") throw e;
if (e instanceof Error && e.name === "TimeoutError") throw e;
if (e instanceof HttpError) throw e;
if (e instanceof Error && e.message.startsWith("Anthropic streaming error")) throw e;
// Transport-level failure (rare with Node https) -- fall through.
}
}
const body: Record<string, unknown> = {
model: request.model,
max_tokens: request.maxTokens,
@@ -144,150 +80,30 @@ export class AnthropicProvider implements LLMService {
],
};
const useThinking = request.useThinking === true && THINKING_CAPABLE.has(request.model);
if (useThinking) {
// Use adaptive thinking (recommended for Claude 4.6+).
// budget_tokens is deprecated on Opus 4.6 and Sonnet 4.6.
if (request.useThinking === true && THINKING_CAPABLE.has(request.model)) {
body.thinking = { type: "adaptive" };
}
const headers: Record<string, string> = {
"x-api-key": apiKey,
"anthropic-version": ANTHROPIC_VERSION,
"content-type": "application/json",
};
if (useThinking) {
headers["anthropic-beta"] = "interleaved-thinking-2025-05-14";
}
const response = await withTimeout(
this.httpFn({
url: ANTHROPIC_API_URL,
method: "POST",
headers,
body: JSON.stringify(body),
}),
request.timeoutMs,
);
const response = await this.httpFn({
url: ANTHROPIC_API_URL,
method: "POST",
headers: {
"x-api-key": apiKey,
"anthropic-version": ANTHROPIC_VERSION,
"content-type": "application/json",
},
body: JSON.stringify(body),
});
if (response.status < 200 || response.status >= 300) {
throw this.buildHttpError(response.status, response.text, response.headers);
throw this.buildHttpError(response.status, response.text);
}
return this.parseResponse(response.text, request.model);
}
/**
* Streaming completion using Node's https module (via streamRequest) + SSE parsing.
* Calls request.onToken with each text delta as it arrives. Supports
* cancellation via request.signal.
*/
private async completeStreaming(
request: CompletionRequest,
apiKey: string,
): Promise<CompletionResponse> {
const useThinking = request.useThinking === true && THINKING_CAPABLE.has(request.model);
const body: Record<string, unknown> = {
model: request.model,
max_tokens: request.maxTokens,
system: request.systemPrompt,
messages: [{ role: "user", content: request.userPrompt }],
stream: true,
};
if (useThinking) {
body.thinking = { type: "adaptive" };
}
const headers: Record<string, string> = {
"x-api-key": apiKey,
"anthropic-version": ANTHROPIC_VERSION,
"content-type": "application/json",
};
if (useThinking) {
headers["anthropic-beta"] = "interleaved-thinking-2025-05-14";
}
let buffer = "";
const textParts: string[] = [];
let inputTokens = 0;
let outputTokens = 0;
// Captured by the SSE parser when an upstream `error` event arrives.
// Thrown after streamRequest resolves so the partial textParts cannot
// be returned as a "successful" CompletionResponse.
let streamError: Error | null = null;
const result = await streamRequest({
url: ANTHROPIC_API_URL,
method: "POST",
headers,
body: JSON.stringify(body),
signal: request.signal,
timeoutMs: request.timeoutMs,
onChunk: (chunk: string) => {
buffer += chunk;
// Parse SSE lines from buffer; keep incomplete last line for next chunk
const lines = buffer.split("\n");
buffer = lines.pop() ?? "";
for (const line of lines) {
if (!line.startsWith("data: ")) continue;
const data = line.slice(6).trim();
if (data === "[DONE]") continue;
try {
const event = JSON.parse(data);
// Upstream error event -- captured for throw after the stream ends.
// Anthropic shape: { type: "error", error: { type, message } }
if (event.type === "error") {
const errType = event.error?.type ?? "error";
const errMsg = event.error?.message ?? "unknown error";
streamError = new Error(`Anthropic streaming error [${errType}]: ${errMsg}`);
continue;
}
// Text delta -- the main output
if (event.type === "content_block_delta" && event.delta?.type === "text_delta") {
const text = event.delta.text;
textParts.push(text);
request.onToken?.(text);
}
// Usage from message_delta (final event)
if (event.type === "message_delta" && event.usage) {
outputTokens = event.usage.output_tokens ?? 0;
}
// Usage from message_start
if (event.type === "message_start" && event.message?.usage) {
inputTokens = event.message.usage.input_tokens ?? 0;
}
} catch {
// Skip unparseable lines
}
}
},
});
// Upstream emitted an `error` SSE event mid-stream; surface it instead
// of returning the partial pre-error tokens as a successful result.
if (streamError) throw streamError;
// streamRequest returns non-2xx as data (not an error); inspect status here.
if (result.status < 200 || result.status >= 300) {
throw this.buildHttpError(result.status, result.fullText, result.headers);
}
return {
text: textParts.join(""),
usage: { inputTokens, outputTokens },
model: request.model,
provider: "anthropic",
};
}
estimateTokens(text: string): number {
const wordCount = text.split(/\s+/).filter((w) => w.length > 0).length;
return Math.ceil(wordCount * TOKEN_MULTIPLIER);
@@ -319,7 +135,7 @@ export class AnthropicProvider implements LLMService {
if (response.status >= 200 && response.status < 300) {
return null; // success
}
return this.buildHttpError(response.status, response.text, response.headers).message;
return this.buildHttpError(response.status, response.text).message;
} catch (err: unknown) {
const message = err instanceof Error ? err.message : String(err);
return `Anthropic connection failed: ${message}`;
@@ -327,15 +143,10 @@ export class AnthropicProvider implements LLMService {
}
/**
* Build a typed HttpError from an Anthropic HTTP error response. Attaches
* status + headers so the retry helper can read Retry-After. Attempts to
* parse the Anthropic error JSON format; falls back to raw text.
* Build a descriptive Error from an Anthropic HTTP error response.
* Attempts to parse the Anthropic error JSON format; falls back to raw text.
*/
private buildHttpError(
status: number,
responseText: string,
headers: Record<string, string> = {},
): HttpError {
private buildHttpError(status: number, responseText: string): Error {
let detail: string;
try {
const data = JSON.parse(responseText);
@@ -348,13 +159,13 @@ export class AnthropicProvider implements LLMService {
detail = responseText;
}
const baseMsg =
status === 401
? `Anthropic API error (401): Invalid API key. ${detail}`
: status === 429
? `Anthropic API error (429): Rate limited. ${detail}`
: `Anthropic API error (${status}): ${detail}`;
return new HttpError(status, headers, responseText, baseMsg);
if (status === 401) {
return new Error(`Anthropic API error (401): Invalid API key. ${detail}`);
}
if (status === 429) {
return new Error(`Anthropic API error (429): Rate limited. ${detail}`);
}
return new Error(`Anthropic API error (${status}): ${detail}`);
}
/**
@@ -389,7 +200,7 @@ export class AnthropicProvider implements LLMService {
(block: Record<string, unknown>) => block.type === "text",
);
if (textBlock && typeof textBlock.text === "string") {
text = textBlock.text;
text = textBlock.text.trim();
}
}
-350
View File
@@ -1,350 +0,0 @@
/**
* PENNY - Google Gemini Provider
*
* Implements the LLMService interface for the Google Generative Language API.
* Supports Gemini 2.5 Pro, 2.5 Flash, and 2.0 Flash model families.
*
* Key features:
* - Static model catalog (GOOGLE_MODELS) for offline settings display
* - Structured error handling with Google-specific status codes
* - API key authentication via query parameter
*
* Uses an injected HTTP function (Obsidian's requestUrl) so this module
* has no direct Obsidian API dependency and can be tested with a mock.
*/
import type {
LLMService,
ModelInfo,
CompletionRequest,
CompletionResponse,
ProviderSettings,
HttpFn,
} from "./service";
import { streamRequest, withTimeout, withRetry, HttpError } from "./node-stream";
const GOOGLE_API_BASE = "https://generativelanguage.googleapis.com/v1beta/models";
/**
* Static model catalog for Google Gemini.
*
* Intentionally hardcoded for offline/static use. Update manually
* when Google ships new model aliases.
*
* Context windows current as of April 2026.
*/
export const GOOGLE_MODELS: ModelInfo[] = [
{
id: "gemini-2.5-pro",
name: "Gemini 2.5 Pro",
contextWindow: 1048576,
},
{
id: "gemini-2.5-flash",
name: "Gemini 2.5 Flash",
contextWindow: 1048576,
},
{
id: "gemini-2.0-flash",
name: "Gemini 2.0 Flash",
contextWindow: 1048576,
},
];
/** Token estimation multiplier: ~1.0 tokens per word for Gemini's tokenizer. */
const TOKEN_MULTIPLIER = 1.0;
export class GoogleProvider implements LLMService {
readonly name = "google";
readonly requiresApiKey = true;
readonly tokenMultiplier = 1.0;
constructor(private httpFn: HttpFn) {}
async getModels(): Promise<ModelInfo[]> {
return GOOGLE_MODELS;
}
async complete(request: CompletionRequest): Promise<CompletionResponse> {
const apiKey = request.apiKey;
if (!apiKey) {
throw new Error("Google API key is required");
}
const maxRetries = request.maxRetries ?? 3;
let started = false;
const wrappedRequest: CompletionRequest = request.onToken
? {
...request,
onToken: (text: string) => {
started = true;
request.onToken!(text);
},
}
: request;
return withRetry(() => this.doOneAttempt(wrappedRequest, apiKey), {
maxAttempts: maxRetries + 1,
canStillRetry: () => !started,
signal: request.signal,
onRetry: request.onRetry,
});
}
private async doOneAttempt(
request: CompletionRequest,
apiKey: string,
): Promise<CompletionResponse> {
// Stream via Node https (streamRequest) when onToken is set. AbortError
// and API errors must propagate; only true transport failures fall back.
if (request.onToken && typeof globalThis.fetch === "function") {
try {
return await this.completeStreaming(request, apiKey);
} catch (e) {
if (e instanceof Error && e.name === "AbortError") throw e;
if (e instanceof Error && e.name === "TimeoutError") throw e;
if (e instanceof HttpError) throw e;
if (e instanceof Error && e.message.startsWith("Google streaming error")) throw e;
// Transport-level failure -- fall through to httpFn.
}
}
const body: Record<string, unknown> = {
contents: [
{
role: "user",
parts: [{ text: request.userPrompt }],
},
],
generationConfig: {
maxOutputTokens: request.maxTokens,
},
};
if (request.systemPrompt) {
body.systemInstruction = {
parts: [{ text: request.systemPrompt }],
};
}
const url = `${GOOGLE_API_BASE}/${request.model}:generateContent?key=${apiKey}`;
const response = await withTimeout(
this.httpFn({
url,
method: "POST",
headers: {
"content-type": "application/json",
},
body: JSON.stringify(body),
}),
request.timeoutMs,
);
if (response.status < 200 || response.status >= 300) {
throw this.buildHttpError(response.status, response.text, response.headers);
}
return this.parseResponse(response.text, request.model);
}
private async completeStreaming(
request: CompletionRequest,
apiKey: string,
): Promise<CompletionResponse> {
const body: Record<string, unknown> = {
contents: [{ role: "user", parts: [{ text: request.userPrompt }] }],
generationConfig: { maxOutputTokens: request.maxTokens },
};
if (request.systemPrompt) {
body.systemInstruction = { parts: [{ text: request.systemPrompt }] };
}
// Google uses streamGenerateContent with alt=sse for SSE streaming
const url = `${GOOGLE_API_BASE}/${request.model}:streamGenerateContent?alt=sse&key=${apiKey}`;
let buffer = "";
const textParts: string[] = [];
// Captured by the SSE parser when an upstream `error` event arrives.
let streamError: Error | null = null;
const result = await streamRequest({
url,
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(body),
signal: request.signal,
timeoutMs: request.timeoutMs,
onChunk: (chunk: string) => {
buffer += chunk;
const lines = buffer.split("\n");
buffer = lines.pop() ?? "";
for (const line of lines) {
if (!line.startsWith("data: ")) continue;
const data = line.slice(6).trim();
try {
const event = JSON.parse(data);
// Upstream error event. Google shape: { error: { code, message, status } }
if (event.error) {
const errStatus = event.error?.status ?? event.error?.code ?? "error";
const errMsg = event.error?.message ?? String(event.error);
streamError = new Error(`Google streaming error [${errStatus}]: ${errMsg}`);
continue;
}
const parts = event.candidates?.[0]?.content?.parts;
if (Array.isArray(parts)) {
for (const part of parts) {
if (typeof part.text === "string") {
textParts.push(part.text);
request.onToken?.(part.text);
}
}
}
} catch { /* skip */ }
}
},
});
if (streamError) throw streamError;
if (result.status < 200 || result.status >= 300) {
throw this.buildHttpError(result.status, result.fullText, result.headers);
}
return {
text: textParts.join(""),
usage: {},
model: request.model,
provider: "google",
};
}
estimateTokens(text: string): number {
const wordCount = text.split(/\s+/).filter((w) => w.length > 0).length;
return Math.ceil(wordCount * TOKEN_MULTIPLIER);
}
async testConnection(settings: ProviderSettings): Promise<string | null> {
if (!settings.apiKey) {
return "Google API key is required";
}
try {
const body = {
contents: [
{
role: "user",
parts: [{ text: "ping" }],
},
],
generationConfig: {
maxOutputTokens: 16,
},
};
const url = `${GOOGLE_API_BASE}/gemini-2.0-flash:generateContent?key=${settings.apiKey}`;
const response = await this.httpFn({
url,
method: "POST",
headers: {
"content-type": "application/json",
},
body: JSON.stringify(body),
});
if (response.status >= 200 && response.status < 300) {
return null; // success
}
return this.buildHttpError(response.status, response.text, response.headers).message;
} catch (err: unknown) {
const message = err instanceof Error ? err.message : String(err);
return `Google connection failed: ${message}`;
}
}
/**
* Build a typed HttpError from a Google HTTP error response. Attaches
* status + headers so the retry helper can read Retry-After.
*/
private buildHttpError(
status: number,
responseText: string,
headers: Record<string, string> = {},
): HttpError {
let detail: string;
try {
const data = JSON.parse(responseText);
if (data?.error?.message) {
detail = data.error.message;
} else {
detail = responseText;
}
} catch {
detail = responseText;
}
const baseMsg =
status === 401
? `Google API error (401): Invalid API key. ${detail}`
: status === 429
? `Google API error (429): Rate limited. ${detail}`
: `Google API error (${status}): ${detail}`;
return new HttpError(status, headers, responseText, baseMsg);
}
/**
* Parse a Google Generative Language API response.
*
* Response shape:
* ```json
* {
* "candidates": [
* { "content": { "parts": [{ "text": "the actual output" }] } }
* ],
* "usageMetadata": { "promptTokenCount": 100, "candidatesTokenCount": 50 }
* }
* ```
*/
private parseResponse(responseText: string, model: string): CompletionResponse {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let data: any;
try {
data = JSON.parse(responseText);
} catch (err) {
if (err instanceof SyntaxError) {
throw new Error("Google API returned malformed response: " + responseText.slice(0, 200));
}
throw err;
}
let text = "";
if (data && Array.isArray(data.candidates) && data.candidates.length > 0) {
const candidate = data.candidates[0];
if (candidate.content && Array.isArray(candidate.content.parts)) {
const textPart = candidate.content.parts.find(
(part: Record<string, unknown>) => typeof part.text === "string",
);
if (textPart) {
text = textPart.text;
}
}
}
const usage = data.usageMetadata
? {
inputTokens: data.usageMetadata.promptTokenCount as number | undefined,
outputTokens: data.usageMetadata.candidatesTokenCount as number | undefined,
}
: undefined;
return {
text,
usage,
model,
provider: this.name,
};
}
}
+3 -12
View File
@@ -1,12 +1,9 @@
/**
* PENNY - Provider Exports
*
* Barrel file that re-exports all provider types, implementations,
* router, and registry. Also provides createRegistry(), the convenience
* factory used by main.ts to bootstrap the provider system.
*
* Import from `./providers` (this file) rather than from individual
* provider files to keep import paths clean.
* Re-exports all provider types, implementations, router, and registry.
* Also provides a convenience function to create and populate a registry
* with all built-in providers using an injected HTTP function.
*/
// Service interface and types
@@ -38,8 +35,6 @@ export { ProviderRegistry } from "./registry";
// Provider implementations
export { AnthropicProvider, ANTHROPIC_MODELS } from "./anthropic";
export { OllamaProvider } from "./ollama";
export { GoogleProvider, GOOGLE_MODELS } from "./google";
export { OpenAIProvider, OPENAI_MODELS } from "./openai";
// -- Convenience factory --
@@ -47,8 +42,6 @@ import type { HttpFn } from "./service";
import { ProviderRegistry } from "./registry";
import { AnthropicProvider } from "./anthropic";
import { OllamaProvider } from "./ollama";
import { GoogleProvider } from "./google";
import { OpenAIProvider } from "./openai";
/**
* Create a fresh ProviderRegistry pre-populated with all built-in providers.
@@ -68,7 +61,5 @@ export function createRegistry(httpFn: HttpFn): ProviderRegistry {
const reg = new ProviderRegistry();
reg.register(new AnthropicProvider(httpFn));
reg.register(new OllamaProvider(httpFn));
reg.register(new GoogleProvider(httpFn));
reg.register(new OpenAIProvider(httpFn));
return reg;
}
-390
View File
@@ -1,390 +0,0 @@
/**
* PENNY - Node HTTP(S) Streaming Client
*
* Obsidian's Electron renderer blocks native `fetch()` to external APIs via CSP,
* but Electron provides full Node integration -- Node's `http` / `https` modules
* bypass the browser security layer entirely. This is the standard pattern for
* streaming HTTP in Obsidian community plugins.
*
* This module exposes a single `streamRequest()` function that:
* - Selects `http` vs `https` based on the URL scheme
* - Emits raw response chunks via `onChunk(string)` as they arrive
* - Supports cancellation via AbortSignal (wired to req.destroy())
* - Returns `{ status, fullText }` once the response ends
*
* SSE parsing is intentionally left to the caller, because each LLM provider
* has its own event shape (Anthropic's `message_delta`, OpenAI's `choices[].delta`,
* Google's `candidates[].content.parts[]`, etc.).
*/
import type { IncomingMessage } from "http";
export interface StreamOptions {
url: string;
method: "POST" | "GET";
headers: Record<string, string>;
body?: string;
signal?: AbortSignal;
/**
* Optional inactivity timeout in milliseconds. When the request goes this
* long without sending or receiving bytes, the request is destroyed and the
* promise rejects with `name === "TimeoutError"` (distinct from AbortError
* so callers can distinguish user-cancellation from server-hang).
*/
timeoutMs?: number;
/** Called for each raw chunk of response text as it arrives. */
onChunk: (text: string) => void;
}
export interface StreamResult {
status: number;
fullText: string;
/** Lowercased response headers; multi-value headers joined with ", ". */
headers: Record<string, string>;
}
/**
* Issue an HTTP(S) request and stream the response body chunk-by-chunk.
*
* Throws an Error with `name === "AbortError"` if the signal aborts.
* Throws an Error with `name === "TimeoutError"` if `timeoutMs` is set and
* elapses without server activity.
* Rejects on network/DNS errors. A non-2xx status is NOT an error here --
* the caller inspects `result.status` and `result.fullText` to decide.
*/
export function streamRequest(opts: StreamOptions): Promise<StreamResult> {
return new Promise((resolve, reject) => {
// Guard: already aborted before we started
if (opts.signal?.aborted) {
const err = new Error("Request aborted");
err.name = "AbortError";
reject(err);
return;
}
const parsed = new URL(opts.url);
const isHttps = parsed.protocol === "https:";
// Require the transport module lazily -- keeps this module importable in
// test environments that may stub it and avoids eager resolution at import time.
// eslint-disable-next-line @typescript-eslint/no-require-imports
const transport = isHttps ? require("https") : require("http");
const requestOptions = {
method: opts.method,
hostname: parsed.hostname,
port: parsed.port || (isHttps ? 443 : 80),
path: parsed.pathname + parsed.search,
headers: {
...opts.headers,
...(opts.body ? { "Content-Length": Buffer.byteLength(opts.body).toString() } : {}),
},
};
const chunks: string[] = [];
let settled = false;
// Marker set by the timeout callback before destroying the request.
// Read in the error handler to disambiguate timeout from abort/transport errors,
// since req.destroy() emits the same ECONNRESET-shaped error in all three cases.
let timedOut = false;
const req = transport.request(requestOptions, (res: IncomingMessage) => {
res.setEncoding("utf8");
res.on("data", (chunk: string) => {
chunks.push(chunk);
try {
opts.onChunk(chunk);
} catch {
// Don't let a consumer throw bring down the whole stream
}
});
res.on("end", () => {
if (settled) return;
settled = true;
resolve({
status: res.statusCode ?? 0,
fullText: chunks.join(""),
headers: normalizeIncomingHeaders(res.headers),
});
});
res.on("error", (err: Error) => {
if (settled) return;
settled = true;
reject(err);
});
});
req.on("error", (err: Error) => {
if (settled) return;
settled = true;
// Order matters: timeout must be checked before abort, since a timeout
// that fires while a signal is also pending could be misclassified.
if (timedOut) {
const timeoutErr = new Error(`Request timed out after ${opts.timeoutMs}ms`);
timeoutErr.name = "TimeoutError";
reject(timeoutErr);
return;
}
// When we call req.destroy() due to abort, Node emits ECONNRESET / socket
// hang up here. Translate to a clean AbortError so callers can detect it.
if (opts.signal?.aborted) {
const aborted = new Error("Request aborted");
aborted.name = "AbortError";
reject(aborted);
return;
}
reject(err);
});
// Wire signal -> req.destroy(). If signal aborts mid-request, the req emits
// "error" which we convert to AbortError above.
const onAbort = () => {
if (settled) return;
req.destroy(new Error("aborted"));
};
opts.signal?.addEventListener("abort", onAbort);
// Wire the inactivity timeout. Node's req.setTimeout(ms, cb) fires cb after
// ms of no socket activity. We mark `timedOut` so the error handler can
// produce a TimeoutError instead of a generic transport error or AbortError.
if (opts.timeoutMs && opts.timeoutMs > 0) {
req.setTimeout(opts.timeoutMs, () => {
if (settled) return;
timedOut = true;
req.destroy(new Error("timeout"));
});
}
// Clean up the listener once we've resolved or rejected
const cleanup = () => {
opts.signal?.removeEventListener("abort", onAbort);
};
req.on("close", cleanup);
if (opts.body) {
req.write(opts.body);
}
req.end();
});
}
/**
* Wrap an arbitrary Promise with a TimeoutError fallback.
*
* Used for the non-streaming provider path, which goes through Obsidian's
* `requestUrl` (no native AbortSignal support). The underlying request keeps
* running in the background after the timeout fires -- there is no way to
* cancel it -- but the promise the caller awaits rejects on time, freeing
* the UI. Node/Electron eventually GCs the orphaned request.
*
* For the streaming path, prefer `streamRequest({ timeoutMs })` which can
* actually destroy the in-flight request via `req.destroy()`.
*
* @param promise The promise to race against the timeout.
* @param timeoutMs Inactivity ceiling in milliseconds. If undefined or <=0,
* the original promise is returned unchanged (no timeout).
* @returns A promise that resolves with the original value, or rejects
* with `name === "TimeoutError"` after `timeoutMs` elapses.
*/
export function withTimeout<T>(promise: Promise<T>, timeoutMs?: number): Promise<T> {
if (!timeoutMs || timeoutMs <= 0) return promise;
return new Promise<T>((resolve, reject) => {
const timer = setTimeout(() => {
const err = new Error(`Request timed out after ${timeoutMs}ms`);
err.name = "TimeoutError";
reject(err);
}, timeoutMs);
promise.then(
(value) => { clearTimeout(timer); resolve(value); },
(err) => { clearTimeout(timer); reject(err); },
);
});
}
/**
* Typed HTTP error carrying the response status, headers, and body.
*
* Thrown by each provider's `buildHttpError` so the retry helper can
* read `Retry-After` and decide whether the failure is retriable.
*/
export class HttpError extends Error {
constructor(
public readonly status: number,
public readonly headers: Record<string, string>,
public readonly body: string,
message?: string,
) {
super(message ?? `HTTP ${status}`);
this.name = "HttpError";
}
}
/**
* Classify a thrown error as retriable or not for `withRetry`.
*
* Retriable: HttpError with status 429/408/5xx, OR transport errors
* (anything that is not HttpError, AbortError, or TimeoutError).
* Non-retriable: AbortError (user cancellation), TimeoutError (the
* user-set inactivity ceiling fired -- retrying would double the wait),
* and HttpError with non-retriable status (other 4xx).
*/
export function isRetriable(err: unknown): boolean {
if (err instanceof Error) {
if (err.name === "AbortError" || err.name === "TimeoutError") return false;
if (err instanceof HttpError) {
return err.status === 429 || err.status === 408 || err.status >= 500;
}
}
return true;
}
const MAX_BACKOFF_MS = 60_000;
/**
* Parse an RFC 7231 `Retry-After` header value into milliseconds.
* Accepts delta-seconds ("30") or HTTP-date ("Wed, 21 Oct 2026 07:28:00 GMT").
* Returns null when the value is missing or unparseable.
* HTTP-dates in the past are clamped to 0 ms (retry immediately).
*/
export function parseRetryAfter(value: string | undefined): number | null {
if (value === undefined || value === "") return null;
const trimmed = value.trim();
if (/^\d+$/.test(trimmed)) {
const seconds = Number(trimmed);
if (Number.isFinite(seconds) && seconds >= 0) return seconds * 1000;
return null;
}
// Require at least one alphabetic character before trying Date.parse so that
// bare-number/sign inputs like "-5" don't sneak through as year-style dates.
if (!/[A-Za-z]/.test(trimmed)) return null;
const ts = Date.parse(trimmed);
if (Number.isFinite(ts)) {
return Math.max(0, ts - Date.now());
}
return null;
}
/**
* Compute the wait between retry attempts.
*
* If `err` is an `HttpError` with a parseable `Retry-After` header, use
* that (capped at 60s). Otherwise, full-jitter exponential backoff:
* delay = random(0, min(60_000, 1000 * 2^attempt))
*
* `attempt` is 0-indexed across retries:
* attempt 0 -> base 1s (wait before retry #1)
* attempt 1 -> base 2s (wait before retry #2)
* attempt 2 -> base 4s (wait before retry #3)
* attempt N -> base min(60s, 2^N s)
*/
export function backoffDelay(err: unknown, attempt: number): number {
if (err instanceof HttpError) {
const parsed = parseRetryAfter(err.headers["retry-after"]);
if (parsed !== null) return Math.min(parsed, MAX_BACKOFF_MS);
}
const base = Math.min(MAX_BACKOFF_MS, 1000 * Math.pow(2, attempt));
return Math.floor(Math.random() * base);
}
/** Information passed to `onRetry` before the helper sleeps and re-attempts. */
export interface RetryInfo {
/** 1-based count of the upcoming attempt. First retry is `attempt: 2`. */
attempt: number;
/** Milliseconds the helper will sleep before the upcoming attempt. */
waitMs: number;
/** Short label describing the cause (e.g. "HTTP 429", "TimeoutError", "transport"). */
reason: string;
}
export interface RetryOpts {
/** Total attempts, including the initial call. `maxAttempts: 3` allows up to 2 retries. */
maxAttempts: number;
/** Optional gate. If returns false, retries are skipped (used to stop after tokens stream). */
canStillRetry?: () => boolean;
/** Optional cancellation signal. Aborts both in-flight attempts and inter-attempt sleeps. */
signal?: AbortSignal;
/** Optional callback invoked before each retry sleep (for logging / progress UI). */
onRetry?: (info: RetryInfo) => void;
}
/**
* Retry an async function up to `maxAttempts` times when the thrown error
* is classified retriable by `isRetriable`. Sleeps between attempts using
* `backoffDelay` (Retry-After honored when present; otherwise full-jitter
* exponential). Cancellation-aware: an aborted signal interrupts both
* in-flight attempts and inter-attempt sleeps.
*/
export async function withRetry<T>(fn: () => Promise<T>, opts: RetryOpts): Promise<T> {
if (opts.signal?.aborted) throw makeAbortError();
let lastErr: unknown;
for (let attempt = 0; attempt < opts.maxAttempts; attempt++) {
try {
return await fn();
} catch (err) {
lastErr = err;
const isFinalAttempt = attempt === opts.maxAttempts - 1;
if (isFinalAttempt) throw err;
if (!isRetriable(err)) throw err;
if (opts.canStillRetry && !opts.canStillRetry()) throw err;
const waitMs = backoffDelay(err, attempt);
const reason = describeRetryReason(err);
opts.onRetry?.({ attempt: attempt + 2, waitMs, reason });
await sleepCancellable(waitMs, opts.signal);
}
}
throw lastErr;
}
function makeAbortError(): Error {
const err = new Error("Request aborted");
err.name = "AbortError";
return err;
}
function describeRetryReason(err: unknown): string {
if (err instanceof HttpError) return `HTTP ${err.status}`;
if (err instanceof Error) return err.name === "Error" ? "transport" : err.name;
return "transport";
}
async function sleepCancellable(ms: number, signal?: AbortSignal): Promise<void> {
if (ms <= 0) return;
return new Promise<void>((resolve, reject) => {
if (signal?.aborted) {
reject(makeAbortError());
return;
}
const onAbort = (): void => {
clearTimeout(timer);
reject(makeAbortError());
};
const timer = setTimeout(() => {
signal?.removeEventListener("abort", onAbort);
resolve();
}, ms);
signal?.addEventListener("abort", onAbort, { once: true });
});
}
/**
* Flatten Node's IncomingHttpHeaders shape (string | string[] | undefined)
* into a lowercased Record<string,string> for downstream consumers (the
* retry helper, providers reading Retry-After, etc.).
*/
function normalizeIncomingHeaders(
raw: NodeJS.Dict<string | string[]>,
): Record<string, string> {
const out: Record<string, string> = {};
for (const [name, value] of Object.entries(raw)) {
if (value === undefined) continue;
out[name.toLowerCase()] = Array.isArray(value) ? value.join(", ") : value;
}
return out;
}
+18 -159
View File
@@ -1,23 +1,13 @@
/**
* PENNY - Ollama Provider
*
* Implements the LLMService interface for Ollama's local model server.
* Allows PENNY to use locally-hosted open-source models (LLaMA, Mistral,
* DeepSeek, etc.) instead of or alongside the Anthropic API.
*
* Uses two Ollama APIs:
* - GET /api/tags -- list locally installed models
* - POST /v1/chat/completions -- OpenAI-compatible chat completions
*
* Key differences from the Anthropic provider:
* - No API key required by default (local server)
* - Model list is dynamically fetched rather than hardcoded
* - Token multiplier is 1.0 (most local tokenizers are ~1:1 word:token)
* - No extended thinking support
* - Connection errors include helpful "Is Ollama running?" messages
*
* Implements LLMService for Ollama's OpenAI-compatible endpoint.
* Uses an injected HTTP function (Obsidian's requestUrl) so this module
* has no direct Obsidian API dependency and can be tested with a mock.
* has no direct Obsidian API dependency.
*
* Ollama exposes two relevant APIs:
* - /api/tags -- list locally available models
* - /v1/chat/completions -- OpenAI-compatible chat completions
*/
import type {
@@ -28,7 +18,6 @@ import type {
ProviderSettings,
HttpFn,
} from "./service";
import { streamRequest, withTimeout, withRetry, HttpError } from "./node-stream";
const DEFAULT_OLLAMA_ENDPOINT = "http://localhost:11434";
@@ -43,7 +32,6 @@ const TOKEN_MULTIPLIER = 1.0;
export class OllamaProvider implements LLMService {
readonly name = "ollama";
readonly requiresApiKey = false;
readonly tokenMultiplier = 1.0;
constructor(private httpFn: HttpFn) {}
@@ -98,46 +86,8 @@ export class OllamaProvider implements LLMService {
}
async complete(request: CompletionRequest): Promise<CompletionResponse> {
const maxRetries = request.maxRetries ?? 3;
let started = false;
const wrappedRequest: CompletionRequest = request.onToken
? {
...request,
onToken: (text: string) => {
started = true;
request.onToken!(text);
},
}
: request;
return withRetry(() => this.doOneAttempt(wrappedRequest), {
maxAttempts: maxRetries + 1,
canStillRetry: () => !started,
signal: request.signal,
onRetry: request.onRetry,
});
}
private async doOneAttempt(request: CompletionRequest): Promise<CompletionResponse> {
const endpoint = request.endpoint ?? DEFAULT_OLLAMA_ENDPOINT;
// Stream via Node http/https (streamRequest) when onToken is set. AbortError
// and API errors must propagate; connection-refused and other transport
// failures fall through to the non-streaming httpFn path as a safety net.
if (request.onToken && typeof globalThis.fetch === "function") {
try {
return await this.completeStreaming(request, endpoint);
} catch (e) {
if (e instanceof Error && e.name === "AbortError") throw e;
if (e instanceof Error && e.name === "TimeoutError") throw e;
if (e instanceof HttpError) throw e;
if (e instanceof Error && e.message.startsWith("Ollama streaming error")) throw e;
// Transport-level failure (ECONNREFUSED, etc.) -- fall through to httpFn,
// which will produce the usual "Is Ollama running?" diagnostic.
}
}
const body = {
model: request.model,
messages: [
@@ -157,15 +107,12 @@ export class OllamaProvider implements LLMService {
let response;
try {
response = await withTimeout(
this.httpFn({
url: `${endpoint}/v1/chat/completions`,
method: "POST",
headers,
body: JSON.stringify(body),
}),
request.timeoutMs,
);
response = await this.httpFn({
url: `${endpoint}/v1/chat/completions`,
method: "POST",
headers,
body: JSON.stringify(body),
});
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
if (message.includes("ECONNREFUSED") || message.includes("fetch failed") || message.includes("Connection refused")) {
@@ -175,94 +122,12 @@ export class OllamaProvider implements LLMService {
}
if (response.status < 200 || response.status >= 300) {
throw this.buildHttpError(response.status, response.text, endpoint, response.headers);
throw this.buildHttpError(response.status, response.text, endpoint);
}
return this.parseResponse(response.text, request.model);
}
private async completeStreaming(
request: CompletionRequest,
endpoint: string,
): Promise<CompletionResponse> {
const body = {
model: request.model,
messages: [
{ role: "system", content: request.systemPrompt },
{ role: "user", content: request.userPrompt },
],
max_tokens: request.maxTokens,
stream: true,
};
const headers: Record<string, string> = { "content-type": "application/json" };
if (request.apiKey) {
headers["Authorization"] = `Bearer ${request.apiKey}`;
}
let buffer = "";
const textParts: string[] = [];
// Captured by the SSE parser when an upstream `error` event arrives.
let streamError: Error | null = null;
const result = await streamRequest({
url: `${endpoint}/v1/chat/completions`,
method: "POST",
headers,
body: JSON.stringify(body),
signal: request.signal,
timeoutMs: request.timeoutMs,
onChunk: (chunk: string) => {
buffer += chunk;
const lines = buffer.split("\n");
buffer = lines.pop() ?? "";
for (const line of lines) {
if (!line.startsWith("data: ")) continue;
const data = line.slice(6).trim();
if (data === "[DONE]") continue;
try {
const event = JSON.parse(data);
// Ollama emits errors in two shapes:
// { "error": "context deadline exceeded" } -- bare string
// { "error": { "message": "...", "type": "..." } } -- OpenAI-compat object
if (event.error) {
let errMsg: string;
let errType: string;
if (typeof event.error === "string") {
errMsg = event.error;
errType = "error";
} else {
errMsg = event.error?.message ?? String(event.error);
errType = event.error?.type ?? "error";
}
streamError = new Error(`Ollama streaming error [${errType}]: ${errMsg}`);
continue;
}
const delta = event.choices?.[0]?.delta?.content;
if (typeof delta === "string") {
textParts.push(delta);
request.onToken?.(delta);
}
} catch { /* skip */ }
}
},
});
if (streamError) throw streamError;
if (result.status < 200 || result.status >= 300) {
throw this.buildHttpError(result.status, result.fullText, endpoint, result.headers);
}
return {
text: textParts.join(""),
usage: {},
model: request.model,
provider: "ollama",
};
}
estimateTokens(text: string): number {
const wordCount = text.split(/\s+/).filter((w) => w.length > 0).length;
return Math.ceil(wordCount * TOKEN_MULTIPLIER);
@@ -280,7 +145,7 @@ export class OllamaProvider implements LLMService {
if (response.status >= 200 && response.status < 300) {
return null; // success
}
return this.buildHttpError(response.status, response.text, endpoint, response.headers).message;
return this.buildHttpError(response.status, response.text, endpoint).message;
} catch (err: unknown) {
const message = err instanceof Error ? err.message : String(err);
if (message.includes("ECONNREFUSED") || message.includes("fetch failed") || message.includes("Connection refused")) {
@@ -291,15 +156,9 @@ export class OllamaProvider implements LLMService {
}
/**
* Build a typed HttpError from an Ollama HTTP error response. Attaches
* status + headers so the retry helper can read Retry-After.
* Build a descriptive Error from an Ollama HTTP error response.
*/
private buildHttpError(
status: number,
responseText: string,
_endpoint: string,
headers: Record<string, string> = {},
): HttpError {
private buildHttpError(status: number, responseText: string, _endpoint: string): Error {
let detail: string;
try {
const data = JSON.parse(responseText);
@@ -307,7 +166,7 @@ export class OllamaProvider implements LLMService {
} catch {
detail = responseText;
}
return new HttpError(status, headers, responseText, `Ollama error (${status}): ${detail}`);
return new Error(`Ollama error (${status}): ${detail}`);
}
/**
@@ -339,7 +198,7 @@ export class OllamaProvider implements LLMService {
if (data && Array.isArray(data.choices) && data.choices.length > 0) {
const choice = data.choices[0];
if (choice.message && typeof choice.message.content === "string") {
text = choice.message.content;
text = choice.message.content.trim();
}
}
-330
View File
@@ -1,330 +0,0 @@
/**
* PENNY - OpenAI Provider
*
* Implements the LLMService interface for the OpenAI Chat Completions API.
* Supports GPT-4.1, o3, and o4-mini model families.
*
* Key features:
* - Static model catalog (OPENAI_MODELS) for offline settings display
* - Structured error handling with OpenAI-specific status codes
* - Bearer token authentication via Authorization header
*
* Uses an injected HTTP function (Obsidian's requestUrl) so this module
* has no direct Obsidian API dependency and can be tested with a mock.
*/
import type {
LLMService,
ModelInfo,
CompletionRequest,
CompletionResponse,
ProviderSettings,
HttpFn,
} from "./service";
import { streamRequest, withTimeout, withRetry, HttpError } from "./node-stream";
const OPENAI_API_URL = "https://api.openai.com/v1/chat/completions";
const OPENAI_MODELS_URL = "https://api.openai.com/v1/models";
/**
* Static model catalog for OpenAI.
*
* Intentionally hardcoded for offline/static use. Update manually
* when OpenAI ships new model aliases.
*
* Context windows current as of April 2026.
*/
export const OPENAI_MODELS: ModelInfo[] = [
{
id: "gpt-4.1",
name: "GPT-4.1",
contextWindow: 1047576,
},
{
id: "gpt-4.1-mini",
name: "GPT-4.1 Mini",
contextWindow: 1047576,
},
{
id: "gpt-4.1-nano",
name: "GPT-4.1 Nano",
contextWindow: 1047576,
},
{
id: "o3",
name: "o3",
contextWindow: 200000,
},
{
id: "o4-mini",
name: "o4-mini",
contextWindow: 200000,
},
];
/** Token estimation multiplier: ~1.0 tokens per word for OpenAI's tokenizer. */
const TOKEN_MULTIPLIER = 1.0;
export class OpenAIProvider implements LLMService {
readonly name = "openai";
readonly requiresApiKey = true;
readonly tokenMultiplier = 1.0;
constructor(private httpFn: HttpFn) {}
async getModels(): Promise<ModelInfo[]> {
return OPENAI_MODELS;
}
async complete(request: CompletionRequest): Promise<CompletionResponse> {
const apiKey = request.apiKey;
if (!apiKey) {
throw new Error("OpenAI API key is required");
}
const maxRetries = request.maxRetries ?? 3;
let started = false;
const wrappedRequest: CompletionRequest = request.onToken
? {
...request,
onToken: (text: string) => {
started = true;
request.onToken!(text);
},
}
: request;
return withRetry(() => this.doOneAttempt(wrappedRequest, apiKey), {
maxAttempts: maxRetries + 1,
canStillRetry: () => !started,
signal: request.signal,
onRetry: request.onRetry,
});
}
private async doOneAttempt(
request: CompletionRequest,
apiKey: string,
): Promise<CompletionResponse> {
// Stream via Node https (streamRequest) when onToken is set. AbortError
// and API errors must propagate; only true transport failures fall back.
if (request.onToken && typeof globalThis.fetch === "function") {
try {
return await this.completeStreaming(request, apiKey);
} catch (e) {
if (e instanceof Error && e.name === "AbortError") throw e;
if (e instanceof Error && e.name === "TimeoutError") throw e;
if (e instanceof HttpError) throw e;
if (e instanceof Error && e.message.startsWith("OpenAI streaming error")) throw e;
// Transport-level failure -- fall through to httpFn.
}
}
const body: Record<string, unknown> = {
model: request.model,
messages: [
{ role: "system", content: request.systemPrompt },
{ role: "user", content: request.userPrompt },
],
max_tokens: request.maxTokens,
};
const response = await withTimeout(
this.httpFn({
url: OPENAI_API_URL,
method: "POST",
headers: {
"Authorization": `Bearer ${apiKey}`,
"content-type": "application/json",
},
body: JSON.stringify(body),
}),
request.timeoutMs,
);
if (response.status < 200 || response.status >= 300) {
throw this.buildHttpError(response.status, response.text, response.headers);
}
return this.parseResponse(response.text, request.model);
}
private async completeStreaming(
request: CompletionRequest,
apiKey: string,
): Promise<CompletionResponse> {
const body = {
model: request.model,
messages: [
{ role: "system", content: request.systemPrompt },
{ role: "user", content: request.userPrompt },
],
max_tokens: request.maxTokens,
stream: true,
};
let buffer = "";
const textParts: string[] = [];
// Captured by the SSE parser when an upstream `error` event arrives.
// Thrown after streamRequest resolves so partial pre-error tokens can't
// surface as a successful CompletionResponse.
let streamError: Error | null = null;
const result = await streamRequest({
url: OPENAI_API_URL,
method: "POST",
headers: { "Authorization": `Bearer ${apiKey}`, "content-type": "application/json" },
body: JSON.stringify(body),
signal: request.signal,
timeoutMs: request.timeoutMs,
onChunk: (chunk: string) => {
buffer += chunk;
const lines = buffer.split("\n");
buffer = lines.pop() ?? "";
for (const line of lines) {
if (!line.startsWith("data: ")) continue;
const data = line.slice(6).trim();
if (data === "[DONE]") continue;
try {
const event = JSON.parse(data);
// Upstream error event. OpenAI shape: { error: { message, type, ... } }
if (event.error) {
const errType = event.error?.type ?? "error";
const errMsg = event.error?.message ?? String(event.error);
streamError = new Error(`OpenAI streaming error [${errType}]: ${errMsg}`);
continue;
}
const delta = event.choices?.[0]?.delta?.content;
if (typeof delta === "string") {
textParts.push(delta);
request.onToken?.(delta);
}
} catch { /* skip */ }
}
},
});
if (streamError) throw streamError;
if (result.status < 200 || result.status >= 300) {
throw this.buildHttpError(result.status, result.fullText, result.headers);
}
return {
text: textParts.join(""),
usage: {},
model: request.model,
provider: "openai",
};
}
estimateTokens(text: string): number {
const wordCount = text.split(/\s+/).filter((w) => w.length > 0).length;
return Math.ceil(wordCount * TOKEN_MULTIPLIER);
}
async testConnection(settings: ProviderSettings): Promise<string | null> {
if (!settings.apiKey) {
return "OpenAI API key is required";
}
try {
const response = await this.httpFn({
url: OPENAI_MODELS_URL,
method: "GET",
headers: {
"Authorization": `Bearer ${settings.apiKey}`,
},
});
if (response.status >= 200 && response.status < 300) {
return null; // success
}
return this.buildHttpError(response.status, response.text, response.headers).message;
} catch (err: unknown) {
const message = err instanceof Error ? err.message : String(err);
return `OpenAI connection failed: ${message}`;
}
}
/**
* Build a typed HttpError from an OpenAI HTTP error response. Attaches
* status + headers so the retry helper can read Retry-After.
*/
private buildHttpError(
status: number,
responseText: string,
headers: Record<string, string> = {},
): HttpError {
let detail: string;
try {
const data = JSON.parse(responseText);
if (data?.error?.message) {
detail = data.error.message;
} else {
detail = responseText;
}
} catch {
detail = responseText;
}
const baseMsg =
status === 401
? `OpenAI API error (401): Invalid API key. ${detail}`
: status === 429
? `OpenAI API error (429): Rate limited. ${detail}`
: `OpenAI API error (${status}): ${detail}`;
return new HttpError(status, headers, responseText, baseMsg);
}
/**
* Parse an OpenAI Chat Completions API response.
*
* Response shape:
* ```json
* {
* "choices": [
* { "message": { "role": "assistant", "content": "response text" } }
* ],
* "usage": { "prompt_tokens": 100, "completion_tokens": 50 },
* "model": "gpt-4.1"
* }
* ```
*/
private parseResponse(responseText: string, model: string): CompletionResponse {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let data: any;
try {
data = JSON.parse(responseText);
} catch (err) {
if (err instanceof SyntaxError) {
throw new Error("OpenAI API returned malformed response: " + responseText.slice(0, 200));
}
throw err;
}
let text = "";
if (data && Array.isArray(data.choices) && data.choices.length > 0) {
const choice = data.choices[0];
if (choice.message && typeof choice.message.content === "string") {
text = choice.message.content;
}
}
const usage = data.usage
? {
inputTokens: data.usage.prompt_tokens as number | undefined,
outputTokens: data.usage.completion_tokens as number | undefined,
}
: undefined;
return {
text,
usage,
model,
provider: this.name,
};
}
}
+3 -10
View File
@@ -1,20 +1,13 @@
/**
* PENNY - Provider Registry
*
* A simple name-keyed registry that stores LLMService instances.
* Populated at plugin load by createRegistry() in providers/index.ts.
* Consumed by:
* - pipeline.ts (getProvider callback looks up providers by name)
* - settings.ts (lists providers for route dropdowns, calls testConnection)
* - commands.ts (requireProvider checks that configured providers exist)
*
* The registry is a thin Map wrapper -- no lifecycle management or
* lazy initialization. Providers are fully constructed at registration time.
* A simple registry that stores LLMService instances by name.
* Providers register at plugin load. The router and drafter look up
* providers by name from this registry.
*/
import type { LLMService } from "./service";
/** Simple name-keyed store for LLM provider instances. */
export class ProviderRegistry {
private providers: Map<string, LLMService> = new Map();
+4 -21
View File
@@ -4,19 +4,11 @@
* Maps annotation tags to complexity tiers, then resolves the tier to a
* specific provider + model via a user-configured RouteConfig.
*
* Tier assignments:
* - Light: CUT, PACING (simple edits, cheaper/faster models)
* - Standard: TONE, EXPAND, PLOT (moderate complexity)
* - Heavy: REWRITE, DIALOG, CHARACTER (needs strongest model)
* Light tasks (CUT, PACING) can use cheaper/faster models.
* Heavy tasks (REWRITE, DIALOG, CHARACTER) get the strongest model.
* Standard is the middle ground.
*
* Unknown tags fall back to "standard".
*
* Also handles the "auto-latest" model resolution: when the user selects
* "Auto (recommended)" in settings, the model field is stored as "auto-latest"
* and resolved at runtime to the best Anthropic model for the tier.
*
* Called by pipeline.ts to determine which provider + model to use for
* each annotation, and by settings.ts to display the resolved model name.
*/
/** Complexity tiers that map to model slots. */
@@ -54,10 +46,6 @@ export interface ResolvedRoute {
* 2. If not found, fall back to "standard".
* 3. Return the provider+model from the config for that tier.
* 4. If the model is "auto-latest", resolve it to the recommended model for that tier.
*
* @param tag - The annotation tag (e.g. "REWRITE")
* @param config - User-configured route assignments per tier
* @returns The resolved provider name and concrete model identifier
*/
export function getRoute(tag: string, config: RouteConfig): ResolvedRoute {
const tier: ComplexityTier = TAG_COMPLEXITY[tag] ?? "standard";
@@ -74,12 +62,7 @@ export function getRoute(tag: string, config: RouteConfig): ResolvedRoute {
*
* When the user selects "Auto (recommended)" in settings, the model
* field is stored as "auto-latest". At runtime this resolves to the
* best Anthropic model for the tier. Non-"auto-latest" model strings
* are returned unchanged (passthrough).
*
* @param model - Model identifier (may be "auto-latest" or a concrete model ID)
* @param tier - Complexity tier for auto-resolution
* @returns Concrete model identifier
* best Anthropic model for the tier.
*/
export function resolveModel(model: string, tier: string): string {
if (model === "auto-latest") {
+14 -56
View File
@@ -1,20 +1,11 @@
/**
* PENNY - LLM Service Interface
*
* Defines the contract that every LLM provider must implement. Adding a
* new provider (e.g. OpenAI, Google) requires:
* 1. Implement the LLMService interface in a new file
* 2. Register it in providers/index.ts createRegistry()
*
* Also defines the HTTP abstraction layer (HttpFn, HttpRequestParam,
* HttpResponse) so providers can make HTTP calls without importing
* Obsidian directly. The real HTTP function (Obsidian's requestUrl)
* is injected at construction time via the provider registry.
*
* This file has no implementation -- it is pure interface definitions.
* Every LLM provider implements this interface. Adding a new provider means
* implementing LLMService and registering it with the ProviderRegistry.
*/
/** HTTP request parameters. Mirrors Obsidian's RequestUrlParam shape so the adapter is thin. */
/** HTTP request parameters -- mirrors Obsidian's RequestUrlParam shape. */
export interface HttpRequestParam {
url: string;
method?: string;
@@ -23,7 +14,7 @@ export interface HttpRequestParam {
contentType?: string;
}
/** HTTP response shape. Mirrors Obsidian's RequestUrlResponse for adapter compatibility. */
/** HTTP response shape -- mirrors Obsidian's RequestUrlResponse. */
export interface HttpResponse {
status: number;
headers: Record<string, string>;
@@ -31,34 +22,28 @@ export interface HttpResponse {
json: unknown;
}
/** Injectable HTTP function type. Obsidian's requestUrl at runtime; a mock in tests. */
/** Injectable HTTP function type (Obsidian's requestUrl or a test mock). */
export type HttpFn = (params: HttpRequestParam) => Promise<HttpResponse>;
/**
* Every LLM provider implements this interface.
* Instances are created by the provider registry (providers/index.ts)
* and looked up by name at runtime by the pipeline.
*/
/** Every LLM provider implements this interface. */
export interface LLMService {
/** Unique provider name used in route configuration (e.g. "anthropic", "ollama"). */
readonly name: string;
/** Whether this provider requires an API key to function. */
readonly requiresApiKey: boolean;
/** Available models for this provider (static list or dynamically fetched from endpoint). */
/** Available models for this provider (static list or fetched from endpoint). */
getModels(settings?: ProviderSettings): Promise<ModelInfo[]>;
/** Send a prompt and get a completion response. The core LLM call. */
/** Send a prompt and get a response. */
complete(request: CompletionRequest): Promise<CompletionResponse>;
/** Rough token estimate for this provider's tokenizer (words * multiplier). */
/** Rough token estimate for this provider's tokenizer. */
estimateTokens(text: string): number;
/** Test connectivity and authentication. Returns an error message or null if OK. */
/** Test connectivity. Returns error message or null if OK. */
testConnection(settings: ProviderSettings): Promise<string | null>;
}
/** Metadata about a model available from a provider. Used in settings dropdowns and cost estimation. */
/** Metadata about a model available from a provider. */
export interface ModelInfo {
id: string;
name: string;
@@ -67,7 +52,7 @@ export interface ModelInfo {
costPer1kOutput?: number;
}
/** Request sent to an LLM provider. Built by drafter.ts, dispatched by pipeline.ts. */
/** Request sent to an LLM provider. */
export interface CompletionRequest {
systemPrompt: string;
userPrompt: string;
@@ -77,36 +62,9 @@ export interface CompletionRequest {
endpoint?: string;
/** When true AND the model supports it, enable extended thinking. */
useThinking?: boolean;
/** Called with each text chunk during streaming. When set, provider should use streaming API. */
onToken?: (text: string) => void;
/**
* Optional cancellation signal. When aborted, the provider tears down any
* in-flight streaming request (req.destroy()) and rejects with an
* `AbortError` (err.name === "AbortError").
*/
signal?: AbortSignal;
/**
* Optional inactivity timeout in milliseconds. If the provider's HTTP call
* goes this long without progress, the call is aborted and the request
* rejects with `name === "TimeoutError"` (distinct from AbortError so the
* pipeline can surface "Request timed out" in review notes / logs).
*/
timeoutMs?: number;
/**
* Optional cap on retries beyond the initial call. `maxRetries: 3` means up
* to 4 total HTTP attempts (1 initial + 3 retries). Defaults to 3 when
* undefined. Set to 0 to disable retry entirely.
*/
maxRetries?: number;
/**
* Optional callback invoked before each retry sleep. The pipeline forwards
* this to a `ProgressEvent("retry", ...)` so the modal can render status.
* Providers themselves never construct ProgressEvents (Obsidian-free).
*/
onRetry?: (info: { attempt: number; waitMs: number; reason: string }) => void;
}
/** Response from an LLM provider. The `text` field contains the revised prose. */
/** Response from an LLM provider. */
export interface CompletionResponse {
text: string;
usage?: { inputTokens?: number; outputTokens?: number };
@@ -114,7 +72,7 @@ export interface CompletionResponse {
provider: string;
}
/** Per-provider settings passed to getModels() and testConnection(). Allows arbitrary extra fields via index signature. */
/** Per-provider settings (API keys, endpoints, etc.). */
export interface ProviderSettings {
apiKey?: string;
endpoint?: string;
-88
View File
@@ -1,88 +0,0 @@
/**
* PENNY - Quote Pool
*
* Static pool of public-domain quotes about writing, craft, and creativity.
* Displayed in the progress modal's "while you wait..." panel.
*
* All authors died before 1930 (public domain in US and most jurisdictions).
* No curation for tone -- a modal full of only serious quotes is depressing;
* one full of only jokes is noise. Mix is the point.
*
* Quotes are attributed inline. pickRandomQuote() selects one at random so
* long runs don't show the same opener twice.
*/
export interface Quote {
text: string;
author: string;
}
export const QUOTES: Quote[] = [
{ text: "The pen is the tongue of the mind.", author: "Miguel de Cervantes" },
{ text: "Easy reading is damn hard writing.", author: "Nathaniel Hawthorne" },
{ text: "Write drunk, edit sober.", author: "Ernest Hemingway" },
{ text: "The first draft of anything is shit.", author: "Ernest Hemingway" },
{ text: "I have rewritten — often several times — every word I have ever published.", author: "Vladimir Nabokov" },
{ text: "There is nothing to writing. All you do is sit down at a typewriter and bleed.", author: "Ernest Hemingway" },
{ text: "Substitute 'damn' every time you're inclined to write 'very'; your editor will delete it and the writing will be just as it should be.", author: "Mark Twain" },
{ text: "The difference between the almost right word and the right word is really a large matter — 'tis the difference between the lightning bug and the lightning.", author: "Mark Twain" },
{ text: "I never write 'metropolis' for seven cents because I can get the same price for 'city'.", author: "Mark Twain" },
{ text: "A sentence should contain no unnecessary words, a paragraph no unnecessary sentences.", author: "William Strunk Jr." },
{ text: "Vigorous writing is concise.", author: "William Strunk Jr." },
{ text: "Omit needless words.", author: "William Strunk Jr." },
{ text: "If I had more time I would have written a shorter letter.", author: "Blaise Pascal" },
{ text: "Writing is the supreme solace.", author: "W. Somerset Maugham" },
{ text: "Read, read, read. Read everything — trash, classics, good and bad, and see how they do it.", author: "William Faulkner" },
{ text: "Don't be 'a writer'. Be writing.", author: "William Faulkner" },
{ text: "The writer must believe that what he is doing is the most important thing in the world.", author: "William Faulkner" },
{ text: "Always be a poet, even in prose.", author: "Charles Baudelaire" },
{ text: "A word after a word after a word is power.", author: "Margaret Atwood" },
{ text: "We write to taste life twice, in the moment and in retrospect.", author: "Anaïs Nin" },
{ text: "I can shake off everything as I write; my sorrows disappear, my courage is reborn.", author: "Anne Frank" },
{ text: "You can make anything by writing.", author: "C. S. Lewis" },
{ text: "There is no greater agony than bearing an untold story inside you.", author: "Maya Angelou" },
{ text: "A reader lives a thousand lives before he dies. The man who never reads lives only one.", author: "George R. R. Martin" },
{ text: "If you don't have time to read, you don't have the time (or the tools) to write.", author: "Stephen King" },
{ text: "The road to hell is paved with adverbs.", author: "Stephen King" },
{ text: "Description begins in the writer's imagination, but should finish in the reader's.", author: "Stephen King" },
{ text: "The scariest moment is always just before you start.", author: "Stephen King" },
{ text: "Either write something worth reading or do something worth writing.", author: "Benjamin Franklin" },
{ text: "Don't tell me the moon is shining; show me the glint of light on broken glass.", author: "Anton Chekhov" },
{ text: "If you want to be a writer, you must do two things above all others: read a lot and write a lot.", author: "Stephen King" },
{ text: "The purpose of a writer is to keep civilization from destroying itself.", author: "Albert Camus" },
{ text: "I write only because there is a voice within me that will not be still.", author: "Sylvia Plath" },
{ text: "Tears are words that need to be written.", author: "Paulo Coelho" },
{ text: "A writer is a world trapped in a person.", author: "Victor Hugo" },
{ text: "Writing is the only thing that, when I do it, I don't feel I should be doing something else.", author: "Gloria Steinem" },
{ text: "No tears in the writer, no tears in the reader.", author: "Robert Frost" },
{ text: "Start writing, no matter what. The water does not flow until the faucet is turned on.", author: "Louis L'Amour" },
{ text: "Writing is easy. All you have to do is cross out the wrong words.", author: "Mark Twain" },
{ text: "Get it down. Take chances. It may be bad, but it's the only way you can do anything really good.", author: "William Faulkner" },
{ text: "I'm writing a first draft and reminding myself that I'm simply shoveling sand into a box so that later I can build castles.", author: "Shannon Hale" },
{ text: "A blank piece of paper is God's way of telling us how hard it is to be God.", author: "Sidney Sheldon" },
{ text: "The secret of getting ahead is getting started.", author: "Mark Twain" },
{ text: "Don't just write what you know — write what you want to know.", author: "Louis L'Amour" },
{ text: "To write is to think, and to write well is to think well.", author: "David McCullough" },
{ text: "Plot is no more than footprints left in the snow after your characters have run by on their way to incredible destinations.", author: "Ray Bradbury" },
{ text: "You must stay drunk on writing so reality cannot destroy you.", author: "Ray Bradbury" },
{ text: "Writing is not life, but I think that sometimes it can be a way back to life.", author: "Stephen King" },
{ text: "The story — from Rumpelstiltskin to War and Peace — is one of the basic tools invented by the human mind.", author: "Ursula K. Le Guin" },
{ text: "I do not over-intellectualise the production process. I try to keep it simple: tell the damned story.", author: "Tom Clancy" },
{ text: "Revision is one of the exquisite pleasures of writing.", author: "Bernard Malamud" },
];
/**
* Pick a random quote from the pool. Uses Math.random() -- quote selection
* is cosmetic; no cryptographic randomness needed.
*/
export function pickRandomQuote(): Quote {
return QUOTES[Math.floor(Math.random() * QUOTES.length)];
}
/**
* Pick a quote at a specific index, wrapping. Useful for a rotating quote
* panel that iterates deterministically from a random starting offset.
*/
export function quoteAt(index: number): Quote {
return QUOTES[((index % QUOTES.length) + QUOTES.length) % QUOTES.length];
}
-9
View File
@@ -2,15 +2,6 @@
* PENNY - Review Note Generation
*
* Produces structured markdown review notes after a processing pass.
* Each review note summarizes what PENNY did: which annotations were
* processed, word count changes, voice compliance metrics, and any
* flags for author attention.
*
* Review files are written to: `{reviewsFolder}/{book}/{chapter}.v{N}-review.md`
*
* Called by pipeline.ts to generate the review content string.
* File I/O (creating the review file) is handled by commands.ts.
*
* Pure function -- no side effects, no file I/O.
*/
+50 -726
View File
@@ -1,30 +1,14 @@
/**
* PENNY - Settings Tab
*
* Obsidian PluginSettingTab subclass that renders the full PENNY settings UI.
* Uses collapsible `<details>` sections for organization:
*
* - Providers: Anthropic API key, Ollama endpoint, connection tests
* - Model Routing: tier-to-model mapping, context budget, max tokens
* - Project Structure: vault paths for drafts, characters, wiki, etc.
* - Voice Rules: custom voice rules textarea, system prompt template
* - Behavior: auto-process, logging, file patterns, prose marker
* - Git Integration: auto-commit, auto-push, commit message format
* - Commands Reference: help text with annotation syntax examples
*
* Created by main.ts at plugin load. Reads from and writes to plugin.settings.
* Re-renders sections dynamically when settings change (e.g. toggling
* useSameModelForAll swaps between single and per-tier model selectors).
* Full settings UI with collapsible sections for API, project structure,
* voice rules, behavior, and git integration.
*/
import { PluginSettingTab, Setting, App, Notice, TFolder, TFile, FuzzySuggestModal } from "obsidian";
import type PennyPlugin from "./main";
import { DEFAULT_SYSTEM_PROMPT } from "./types";
import type { LogLevel, PennySettings } from "./types";
import { showPennyError } from "./error-modal";
import { ANTHROPIC_MODELS } from "./providers/anthropic";
import { GOOGLE_MODELS } from "./providers/google";
import { OPENAI_MODELS } from "./providers/openai";
import { resolveModel } from "./providers/router";
/**
@@ -82,9 +66,7 @@ export class PennySettingTab extends PluginSettingTab {
this.renderProjectStructureSection(containerEl);
this.renderVoiceSection(containerEl);
this.renderBehaviorSection(containerEl);
this.renderUISection(containerEl);
this.renderGitSection(containerEl);
this.renderCompanionPluginsSection(containerEl);
this.renderHelpSection(containerEl);
}
@@ -122,7 +104,7 @@ export class PennySettingTab extends PluginSettingTab {
);
// Anthropic models -- show static list + fetch from API if key is set
new Setting(details)
const anthropicModelsSetting = new Setting(details)
.setName("Available Claude models")
.setDesc("Models available for use with Anthropic.");
const anthropicModelListEl = details.createEl("div", { cls: "penny-model-list" });
@@ -143,9 +125,10 @@ export class PennySettingTab extends PluginSettingTab {
apiKey: this.plugin.settings.anthropicApiKey,
});
if (result) {
showPennyError(this.app,
"Anthropic connection failed",
result);
new Notice(
`PENNY: Anthropic connection failed -- ${result}`,
6000,
);
} else {
new Notice(
"PENNY: Anthropic connection OK.",
@@ -154,7 +137,10 @@ export class PennySettingTab extends PluginSettingTab {
}
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
showPennyError(this.app, "Anthropic test failed", msg);
new Notice(
`PENNY: Test failed -- ${msg}`,
6000,
);
} finally {
button.setButtonText("Test Connection");
button.setDisabled(false);
@@ -178,14 +164,7 @@ export class PennySettingTab extends PluginSettingTab {
t.inputEl.style.width = "300px";
})
.onChange(async (value) => {
const trimmed = value.trim();
if (trimmed && !trimmed.startsWith("http://") && !trimmed.startsWith("https://")) {
showPennyError(this.app,
"Invalid Ollama endpoint",
"Ollama endpoint must start with http:// or https://");
return;
}
this.plugin.settings.ollamaEndpoint = trimmed;
this.plugin.settings.ollamaEndpoint = value.trim();
await this.plugin.saveSettings();
})
);
@@ -239,9 +218,10 @@ export class PennySettingTab extends PluginSettingTab {
apiKey: this.plugin.settings.ollamaApiKey,
});
if (result) {
showPennyError(this.app,
"Ollama connection failed",
result);
new Notice(
`PENNY: Ollama test failed -- ${result}`,
6000,
);
} else {
new Notice(
"PENNY: Ollama connection OK.",
@@ -250,131 +230,10 @@ export class PennySettingTab extends PluginSettingTab {
}
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
showPennyError(this.app, "Ollama test failed", msg);
} finally {
button.setButtonText("Test Connection");
button.setDisabled(false);
}
})
);
// -- Google Gemini --
details.createEl("h4", { text: "Google Gemini" });
new Setting(details)
.setName("Google API key")
.setDesc(
"Your Google Gemini API key. Get one at aistudio.google.com. Stored locally in plugin data."
)
.addText((text) =>
text
.setPlaceholder("AIza...")
.setValue(this.plugin.settings.googleApiKey)
.then((t) => {
t.inputEl.type = "password";
t.inputEl.style.width = "300px";
})
.onChange(async (value) => {
this.plugin.settings.googleApiKey = value.trim();
await this.plugin.saveSettings();
})
);
// Google models -- show static list
new Setting(details)
.setName("Available Gemini models")
.setDesc("Models available for use with Google Gemini.");
const googleModelListEl = details.createEl("div", { cls: "penny-model-list" });
this.loadGoogleModels(googleModelListEl);
new Setting(details)
.setName("Test Google connection")
.setDesc("Verify that your API key is valid.")
.addButton((button) =>
button.setButtonText("Test Connection").onClick(async () => {
button.setButtonText("Testing...");
button.setDisabled(true);
try {
const provider = this.plugin.providerRegistry.get("google");
if (!provider) throw new Error("Google provider not registered");
const result = await provider.testConnection({
apiKey: this.plugin.settings.googleApiKey,
});
if (result) {
showPennyError(this.app,
"Google connection failed",
result);
} else {
new Notice(
"PENNY: Google Gemini connection OK.",
4000,
);
}
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
showPennyError(this.app, "Google Gemini test failed", msg);
} finally {
button.setButtonText("Test Connection");
button.setDisabled(false);
}
})
);
// -- OpenAI --
details.createEl("h4", { text: "OpenAI" });
new Setting(details)
.setName("OpenAI API key")
.setDesc(
"Your OpenAI API key. Get one at platform.openai.com. Stored locally in plugin data."
)
.addText((text) =>
text
.setPlaceholder("sk-...")
.setValue(this.plugin.settings.openaiApiKey)
.then((t) => {
t.inputEl.type = "password";
t.inputEl.style.width = "300px";
})
.onChange(async (value) => {
this.plugin.settings.openaiApiKey = value.trim();
await this.plugin.saveSettings();
})
);
// OpenAI models -- show static list
new Setting(details)
.setName("Available OpenAI models")
.setDesc("Models available for use with OpenAI.");
const openaiModelListEl = details.createEl("div", { cls: "penny-model-list" });
this.loadOpenAIModels(openaiModelListEl);
new Setting(details)
.setName("Test OpenAI connection")
.setDesc("Verify that your API key is valid.")
.addButton((button) =>
button.setButtonText("Test Connection").onClick(async () => {
button.setButtonText("Testing...");
button.setDisabled(true);
try {
const provider = this.plugin.providerRegistry.get("openai");
if (!provider) throw new Error("OpenAI provider not registered");
const result = await provider.testConnection({
apiKey: this.plugin.settings.openaiApiKey,
});
if (result) {
showPennyError(this.app,
"OpenAI connection failed",
result);
} else {
new Notice(
"PENNY: OpenAI connection OK.",
4000,
);
}
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
showPennyError(this.app, "OpenAI test failed", msg);
new Notice(
`PENNY: Ollama test failed -- ${msg}`,
6000,
);
} finally {
button.setButtonText("Test Connection");
button.setDisabled(false);
@@ -412,22 +271,13 @@ export class PennySettingTab extends PluginSettingTab {
})
);
// Compact routing grid
const grid = details.createDiv({ cls: "penny-route-grid" });
// Column headers
const header = grid.createDiv({ cls: "penny-route-header" });
header.createSpan({ text: "Tier" });
header.createSpan({ text: "Provider / Model" });
if (this.plugin.settings.useSameModelForAll) {
this.renderRouteRow(grid, "All tiers", "routeStandard", providerNames, true);
// Single row -- edits routeStandard and mirrors to light/heavy
this.renderRouteRow(details, "All tiers", "routeStandard", providerNames, true);
} else {
this.renderRouteRow(grid, "Light", "routeLight", providerNames);
this.renderRouteRow(grid, "Standard", "routeStandard", providerNames);
this.renderRouteRow(grid, "Heavy", "routeHeavy", providerNames);
grid.createDiv({ cls: "penny-route-divider" });
this.renderRouteRow(grid, "Research", "routeResearch", providerNames);
this.renderRouteRow(details, "Light (CUT, PACING)", "routeLight", providerNames);
this.renderRouteRow(details, "Standard (TONE, EXPAND, PLOT)", "routeStandard", providerNames);
this.renderRouteRow(details, "Heavy (REWRITE, DIALOG, CHARACTER)", "routeHeavy", providerNames);
}
// Context budget (moved here from old API section since it relates to routing)
@@ -442,35 +292,10 @@ export class PennySettingTab extends PluginSettingTab {
.setValue(String(this.plugin.settings.contextBudget))
.onChange(async (value) => {
const parsed = parseInt(value, 10);
if (isNaN(parsed) || parsed <= 0) {
new Notice("PENNY: Context budget must be a positive number.");
text.setValue(String(this.plugin.settings.contextBudget));
return;
if (!isNaN(parsed) && parsed > 0) {
this.plugin.settings.contextBudget = parsed;
await this.plugin.saveSettings();
}
this.plugin.settings.contextBudget = parsed;
await this.plugin.saveSettings();
})
);
// Max output tokens
new Setting(details)
.setName("Max output tokens")
.setDesc(
"Maximum tokens the LLM can generate per revision. Higher = longer output allowed but more expensive. Default: 16000."
)
.addText((text) =>
text
.setPlaceholder("16000")
.setValue(String(this.plugin.settings.maxTokens))
.onChange(async (value) => {
const parsed = parseInt(value, 10);
if (isNaN(parsed) || parsed <= 0) {
new Notice("PENNY: Max output tokens must be a positive number.");
text.setValue(String(this.plugin.settings.maxTokens));
return;
}
this.plugin.settings.maxTokens = parsed;
await this.plugin.saveSettings();
})
);
}
@@ -484,15 +309,13 @@ export class PennySettingTab extends PluginSettingTab {
private renderRouteRow(
container: HTMLElement,
label: string,
routeKey: "routeLight" | "routeStandard" | "routeHeavy" | "routeResearch",
routeKey: "routeLight" | "routeStandard" | "routeHeavy",
providerNames: string[],
mirrorAll = false,
): void {
const route = this.plugin.settings[routeKey];
const anthropicModels = ANTHROPIC_MODELS.map((m) => ({ id: m.id, name: m.name }));
const googleModels = GOOGLE_MODELS.map((m) => ({ id: m.id, name: m.name }));
const openaiModels = OPENAI_MODELS.map((m) => ({ id: m.id, name: m.name }));
const setting = new Setting(container).setName(label);
@@ -504,13 +327,9 @@ export class PennySettingTab extends PluginSettingTab {
dropdown.setValue(route.provider);
dropdown.onChange(async (value) => {
route.provider = value;
// When switching providers, default to a sensible model if the current one doesn't match
// When switching to Ollama, keep model text; switching to Anthropic, default to sonnet
if (value === "anthropic" && !anthropicModels.some((m) => m.id === route.model)) {
route.model = "claude-sonnet-4-6";
} else if (value === "google" && !googleModels.some((m) => m.id === route.model)) {
route.model = "gemini-2.5-flash";
} else if (value === "openai" && !openaiModels.some((m) => m.id === route.model)) {
route.model = "gpt-4.1";
}
if (mirrorAll) {
this.plugin.settings.routeLight = { ...route };
@@ -521,18 +340,14 @@ export class PennySettingTab extends PluginSettingTab {
});
});
// Model selector: dropdown for providers with static catalogs, text input for Ollama
// Model selector: dropdown for Anthropic, text input for Ollama
if (route.provider === "anthropic") {
// Determine which tier this route represents (for display purposes)
const tierLabel = routeKey === "routeLight" ? "light"
: routeKey === "routeHeavy" ? "heavy"
: routeKey === "routeResearch" ? "standard"
: "standard";
const resolved = resolveModel("auto-latest", tierLabel);
// Show a clean short name: "Auto (Opus 4.6)" instead of the full model ID
const shortName = resolved.replace("claude-", "").replace(/-/g, " ")
.replace(/\b\w/g, (c) => c.toUpperCase());
const autoLabel = `Auto (${shortName})`;
const autoResolvedModel = resolveModel("auto-latest", tierLabel);
const autoLabel = `Auto (recommended) -> ${autoResolvedModel}`;
setting.addDropdown((dropdown) => {
dropdown.addOption("auto-latest", autoLabel);
@@ -549,36 +364,6 @@ export class PennySettingTab extends PluginSettingTab {
await this.plugin.saveSettings();
});
});
} else if (route.provider === "google") {
setting.addDropdown((dropdown) => {
for (const m of googleModels) {
dropdown.addOption(m.id, m.name);
}
dropdown.setValue(route.model);
dropdown.onChange(async (value) => {
route.model = value;
if (mirrorAll) {
this.plugin.settings.routeLight = { ...route };
this.plugin.settings.routeHeavy = { ...route };
}
await this.plugin.saveSettings();
});
});
} else if (route.provider === "openai") {
setting.addDropdown((dropdown) => {
for (const m of openaiModels) {
dropdown.addOption(m.id, m.name);
}
dropdown.setValue(route.model);
dropdown.onChange(async (value) => {
route.model = value;
if (mirrorAll) {
this.plugin.settings.routeLight = { ...route };
this.plugin.settings.routeHeavy = { ...route };
}
await this.plugin.saveSettings();
});
});
} else {
// Ollama: dropdown if models are cached, text input as fallback
if (this.ollamaModels.length > 0) {
@@ -647,7 +432,7 @@ export class PennySettingTab extends PluginSettingTab {
);
const pathSettings: Array<{
key: keyof PennySettings;
key: keyof typeof this.plugin.settings;
name: string;
desc: string;
placeholder: string;
@@ -706,12 +491,6 @@ export class PennySettingTab extends PluginSettingTab {
desc: "Where PENNY writes review notes after processing.",
placeholder: "07-reviews",
},
{
key: "researchFolder",
name: "Research folder",
desc: "Where PENNY saves research notes, organized by topic subfolder.",
placeholder: "06-reference/research",
},
{
key: "activityLogFolder",
name: "Activity log folder",
@@ -813,9 +592,6 @@ export class PennySettingTab extends PluginSettingTab {
text
.setValue(this.plugin.settings.systemPromptTemplate)
.onChange(async (value) => {
if (!value.includes("{chapter}")) {
new Notice("PENNY: Warning -- your prompt template is missing {chapter}. The LLM won't see the chapter content.");
}
this.plugin.settings.systemPromptTemplate = value;
await this.plugin.saveSettings();
})
@@ -865,93 +641,19 @@ export class PennySettingTab extends PluginSettingTab {
);
new Setting(details)
.setName("Request timeout (seconds)")
.setName("Verbose logging")
.setDesc(
"Maximum inactivity time per LLM call before PENNY gives up and reports a timeout error. Prevents the modal from hanging forever on a stalled connection. Default: 300 (5 minutes). Set to 0 to disable."
)
.addText((text) =>
text
.setPlaceholder("300")
.setValue(String(Math.round(this.plugin.settings.requestTimeoutMs / 1000)))
.onChange(async (value) => {
const parsed = parseInt(value, 10);
if (isNaN(parsed) || parsed < 0) {
new Notice("PENNY: Request timeout must be a non-negative number of seconds.");
text.setValue(String(Math.round(this.plugin.settings.requestTimeoutMs / 1000)));
return;
}
this.plugin.settings.requestTimeoutMs = parsed * 1000;
await this.plugin.saveSettings();
})
);
new Setting(details)
.setName("Max retries on transient errors")
.setDesc(
"Number of retries on top of the initial LLM call when the API returns 429, 408, 5xx, or a transport error. Default: 3. Set to 0 to disable retry entirely. Honors Retry-After when present, otherwise full-jitter exponential backoff capped at 60 seconds per attempt."
)
.addText((text) =>
text
.setPlaceholder("3")
.setValue(String(this.plugin.settings.maxRetries))
.onChange(async (value) => {
const parsed = parseInt(value, 10);
if (isNaN(parsed) || parsed < 0 || parsed > 10) {
new Notice("PENNY: Max retries must be an integer between 0 and 10.");
text.setValue(String(this.plugin.settings.maxRetries));
return;
}
this.plugin.settings.maxRetries = parsed;
await this.plugin.saveSettings();
})
);
new Setting(details)
.setName("Show progress modal")
.setDesc(
"Open a progress modal during processing showing real-time annotation status. You can minimize it to continue working."
"Write detailed activity entries to the log. Useful for debugging but increases log file size."
)
.addToggle((toggle) =>
toggle
.setValue(this.plugin.settings.showProgressModal)
.setValue(this.plugin.settings.verboseLogging)
.onChange(async (value) => {
this.plugin.settings.showProgressModal = value;
this.plugin.settings.verboseLogging = value;
await this.plugin.saveSettings();
})
);
new Setting(details)
.setName("Show status notices")
.setDesc(
"Show notice popups for processing status updates. Active when the progress modal is minimized or disabled."
)
.addToggle((toggle) =>
toggle
.setValue(this.plugin.settings.showStatusNotices)
.onChange(async (value) => {
this.plugin.settings.showStatusNotices = value;
await this.plugin.saveSettings();
})
);
new Setting(details)
.setName("Log level")
.setDesc(
"Controls how much detail PENNY writes to the developer console (Cmd+Opt+I). Debug = maximum detail, Off = silent."
)
.addDropdown((dropdown) => {
dropdown.addOption("debug", "Debug (maximum detail)");
dropdown.addOption("info", "Info");
dropdown.addOption("warn", "Warnings only");
dropdown.addOption("error", "Errors only");
dropdown.addOption("off", "Off (silent)");
dropdown.setValue(this.plugin.settings.logLevel);
dropdown.onChange(async (value) => {
this.plugin.settings.logLevel = value as LogLevel;
await this.plugin.saveSettings();
});
});
new Setting(details)
.setName("Chapter file pattern")
.setDesc(
@@ -1050,42 +752,6 @@ export class PennySettingTab extends PluginSettingTab {
}
}
/**
* Display Google Gemini models (static catalog) in the given container element.
*/
private async loadGoogleModels(containerEl: HTMLElement): Promise<void> {
containerEl.empty();
for (const m of GOOGLE_MODELS) {
const row = containerEl.createEl("div", { cls: "penny-model-row" });
row.createEl("span", { text: m.name, cls: "penny-model-name" });
row.createEl("span", { text: ` (${m.id})` });
if (m.contextWindow) {
row.createEl("span", {
text: ` -- ${(m.contextWindow / 1000).toFixed(0)}K context`,
cls: "penny-model-meta",
});
}
}
}
/**
* Display OpenAI models (static catalog) in the given container element.
*/
private async loadOpenAIModels(containerEl: HTMLElement): Promise<void> {
containerEl.empty();
for (const m of OPENAI_MODELS) {
const row = containerEl.createEl("div", { cls: "penny-model-row" });
row.createEl("span", { text: m.name, cls: "penny-model-name" });
row.createEl("span", { text: ` (${m.id})` });
if (m.contextWindow) {
row.createEl("span", {
text: ` -- ${(m.contextWindow / 1000).toFixed(0)}K context`,
cls: "penny-model-meta",
});
}
}
}
private async detectProjectStructure(): Promise<void> {
const vault = this.plugin.app.vault;
const allFolders = vault.getAllLoadedFiles().filter((f) => f instanceof TFolder) as TFolder[];
@@ -1138,7 +804,9 @@ export class PennySettingTab extends PluginSettingTab {
`PENNY: Detected ${detectedCount} path(s):\n${detected.join("\n")}`,
8000,
);
this.redisplayPreservingState();
// Don't call this.display() here -- it closes the section.
// Settings are saved; user sees the detected paths in the Notice.
// They can collapse/reopen the section to see updated values.
} else {
new Notice(
"PENNY: No project structure detected, or all paths are already configured.",
@@ -1147,24 +815,6 @@ export class PennySettingTab extends PluginSettingTab {
}
}
/**
* Re-render the settings UI while preserving the open/closed state of
* all `<details>` sections so the user doesn't lose their place.
*/
private redisplayPreservingState(): void {
const { containerEl } = this;
const openStates: boolean[] = [];
containerEl.querySelectorAll("details").forEach((d) => {
openStates.push(d.open);
});
this.display();
containerEl.querySelectorAll("details").forEach((d, i) => {
if (i < openStates.length) d.open = openStates[i];
});
}
/**
* Validate that all configured project paths exist in the vault.
*/
@@ -1213,54 +863,6 @@ export class PennySettingTab extends PluginSettingTab {
);
}
/**
* UI Options -- toggle ribbon icon, context menu, status bar.
*/
private renderUISection(containerEl: HTMLElement): void {
const details = containerEl.createEl("details");
details.createEl("summary", { text: "UI Options" });
details.createEl("p", {
text: "Control which PENNY interface elements are visible. Commands are always available via the command palette (Cmd/Ctrl+P) regardless of these settings.",
cls: "setting-item-description",
});
new Setting(details)
.setName("Ribbon icon")
.setDesc("Show the PENNY pen icon in the left sidebar. Click it to open the command menu.")
.addToggle((toggle) =>
toggle
.setValue(this.plugin.settings.showRibbonIcon)
.onChange(async (value) => {
this.plugin.settings.showRibbonIcon = value;
await this.plugin.saveSettings();
})
);
new Setting(details)
.setName("Right-click context menu")
.setDesc("Add PENNY commands (Process, Dry run, Status, Research) to the editor right-click menu.")
.addToggle((toggle) =>
toggle
.setValue(this.plugin.settings.showContextMenu)
.onChange(async (value) => {
this.plugin.settings.showContextMenu = value;
await this.plugin.saveSettings();
})
);
new Setting(details)
.setName("Status bar")
.setDesc("Show annotation count and chapter info in the bottom status bar.")
.addToggle((toggle) =>
toggle
.setValue(this.plugin.settings.showStatusBar)
.onChange(async (value) => {
this.plugin.settings.showStatusBar = value;
await this.plugin.saveSettings();
})
);
}
/**
* Git -- auto-commit, auto-push, commit message format.
*/
@@ -1278,29 +880,21 @@ export class PennySettingTab extends PluginSettingTab {
.setValue(this.plugin.settings.autoCommitAfterProcessing)
.onChange(async (value) => {
this.plugin.settings.autoCommitAfterProcessing = value;
if (!value) {
this.plugin.settings.autoPushAfterCommit = false;
}
await this.plugin.saveSettings();
this.display(); // Re-render to update autoPush dependency state
})
);
const autoPushSetting = new Setting(details)
new Setting(details)
.setName("Auto-push after commit")
.setDesc("Automatically push to remote after committing. Requires 'Auto-commit after processing' to be enabled.")
.addToggle((toggle) => {
.setDesc("Automatically push to remote after committing.")
.addToggle((toggle) =>
toggle
.setValue(this.plugin.settings.autoPushAfterCommit)
.onChange(async (value) => {
this.plugin.settings.autoPushAfterCommit = value;
await this.plugin.saveSettings();
});
toggle.setDisabled(!this.plugin.settings.autoCommitAfterProcessing);
});
if (!this.plugin.settings.autoCommitAfterProcessing) {
autoPushSetting.settingEl.style.opacity = "0.5";
}
})
);
new Setting(details)
.setName("Commit message format")
@@ -1334,17 +928,16 @@ export class PennySettingTab extends PluginSettingTab {
const commands: Array<{ name: string; desc: string }> = [
{ name: "PENNY: Initialize project", desc: "Scaffold a new novel project structure" },
{ name: "PENNY: Process this chapter", desc: "Process annotations in the active chapter (Cmd/Ctrl+Shift+P)" },
{ name: "PENNY: Process this chapter", desc: "Process annotations in the active chapter" },
{ name: "PENNY: Process all chapters", desc: "Process all annotated chapters in the current book" },
{ name: "PENNY: Dry run", desc: "Show what would change without processing (Cmd/Ctrl+Shift+D)" },
{ name: "PENNY: Dry run", desc: "Show what would change without processing" },
{ name: "PENNY: Migrate chapters", desc: "Convert flat chapter files to versioned folders" },
{ name: "PENNY: Show status", desc: "Show annotation counts and version info (Cmd/Ctrl+Shift+S)" },
{ name: "PENNY: Show status", desc: "Show annotation counts and version info" },
{ name: "PENNY: New chapter", desc: "Create a new chapter from template" },
{ name: "PENNY: New character", desc: "Create a new character from template" },
{ name: "PENNY: Commit progress", desc: "Git commit with auto-generated message" },
{ name: "PENNY: Push", desc: "Git push to remote" },
{ name: "PENNY: Commit and push", desc: "Both in one action" },
{ name: "PENNY: Do research", desc: "Research a topic and save to research folder (Cmd/Ctrl+Shift+R)" },
];
const table = details.createEl("table", { cls: "penny-commands-table" });
@@ -1360,278 +953,9 @@ export class PennySettingTab extends PluginSettingTab {
row.createEl("td", { text: cmd.desc });
}
// --- Annotation Directives Reference ---
details.createEl("h4", { text: "Annotation Directives", attr: { style: "margin-top: 20px;" } });
details.createEl("p", {
text: "Add these annotations to your chapter prose using Obsidian hidden comments. PENNY processes them on the next run.",
cls: "setting-item-description",
});
details.createEl("p", {
text: "Syntax: %% TAG: your instruction %%",
cls: "penny-help-tip",
});
const directives: Array<{ tag: string; desc: string; example: string }> = [
{ tag: "REWRITE", desc: "Replace this passage with a new version", example: "%% REWRITE: More atmosphere -- rain, neon reflections, city sounds %%" },
{ tag: "EXPAND", desc: "Add depth, detail, or length to this passage", example: "%% EXPAND: Show her internal conflict through physical actions %%" },
{ tag: "CUT", desc: "Shorten or remove this passage", example: "%% CUT: Too repetitive. Trim to 2 sentences max %%" },
{ tag: "TONE", desc: "Adjust the voice or mood of this passage", example: "%% TONE: Too formal. Should be casual and fast-paced %%" },
{ tag: "DIALOG", desc: "Rework dialogue for voice, realism, or subtext", example: "%% DIALOG: Tim's voice is wrong here. Check his character sheet %%" },
{ tag: "CHARACTER", desc: "Fix character voice or behavior to match their profile", example: "%% CHARACTER: She wouldn't say this. Too passive. She's direct %%" },
{ tag: "PACING", desc: "Speed up or slow down the passage", example: "%% PACING: This drags. Compress into quick beats %%" },
{ tag: "PLOT", desc: "Flag or fix a continuity/plot issue", example: "%% PLOT: Tyler was established in Chicago in ch-03. Fix %%" },
{ tag: "NOTE", desc: "Author note (NOT processed by PENNY)", example: "%% NOTE: Come back to this after writing ch-06 %%" },
{ tag: "RESEARCH", desc: "Needs fact-checking (NOT processed, flagged in review)", example: "%% RESEARCH: Is this tech accurate for the timeline? %%" },
];
const dTable = details.createEl("table", { cls: "penny-commands-table" });
const dHead = dTable.createEl("thead");
const dHeaderRow = dHead.createEl("tr");
dHeaderRow.createEl("th", { text: "Tag" });
dHeaderRow.createEl("th", { text: "What it does" });
dHeaderRow.createEl("th", { text: "Example" });
const dBody = dTable.createEl("tbody");
for (const d of directives) {
const row = dBody.createEl("tr");
row.createEl("td", { text: d.tag, cls: "penny-command-name" });
row.createEl("td", { text: d.desc });
const exCell = row.createEl("td");
exCell.createEl("code", { text: d.example, attr: { style: "font-size: 0.85em; word-break: break-word;" } });
}
details.createEl("h4", { text: "How Annotations Target Text", attr: { style: "margin-top: 16px;" } });
details.createEl("p", {
text: "Annotations always go AFTER the text they target. PENNY looks upward from the annotation to find what it applies to.",
cls: "setting-item-description",
attr: { style: "font-weight: 600; margin-bottom: 8px;" },
});
const scopeRules: Array<{ title: string; desc: string; example: string }> = [
{
title: "Single paragraph",
desc: "Put the annotation on its own line after the paragraph.",
example: "She walked into the room and sat down.\n%% REWRITE: More atmosphere %%",
},
{
title: "Multiple paragraphs (block scope)",
desc: "Wrap the target text in {{ }} markers. Put the annotation after the closing }}.",
example: "{{\nFirst paragraph of the scene.\n\nSecond paragraph.\n\nThird paragraph.\n}}\n%% REWRITE: Rewrite this whole section %%",
},
{
title: "Inline (within a line)",
desc: "Put the annotation on the same line as the text.",
example: "She felt happy. %% TONE: too direct, show don't tell %%",
},
{
title: "Full section (under a heading)",
desc: "Put the annotation right after a heading. Targets everything until the next heading of the same level.",
example: "## The Coffee Ritual\n%% REWRITE: Change to espresso %%\n\nShe ground the beans...",
},
];
for (const rule of scopeRules) {
const ruleDiv = details.createDiv({ attr: { style: "margin: 12px 0; padding: 10px 14px; border: 1px solid var(--background-modifier-border); border-radius: 6px;" } });
ruleDiv.createEl("strong", { text: rule.title, attr: { style: "display: block; margin-bottom: 4px;" } });
ruleDiv.createEl("span", { text: rule.desc, cls: "setting-item-description", attr: { style: "display: block; margin-bottom: 6px;" } });
ruleDiv.createEl("pre", {
text: rule.example,
attr: { style: "background: var(--background-secondary); padding: 8px 12px; border-radius: 4px; font-size: 0.82em; white-space: pre-wrap; margin: 0;" },
});
}
const tip = details.createEl("p", { cls: "penny-help-tip" });
tip.createEl("strong", { text: "Tip: " });
tip.appendText("The {{ }} block scope is the easiest way to target multiple paragraphs. Press Cmd/Ctrl+P and type \"PENNY\" to see all commands.");
}
// ---------------------------------------------------------------------------
// Companion Plugins
// ---------------------------------------------------------------------------
/** PENNY commands that companion plugins should know about. */
private static readonly PENNY_COMMANDS = [
{ id: "penny:process-chapter", name: "PENNY: Process chapter", icon: "zap" },
{ id: "penny:process-all", name: "PENNY: Process all", icon: "layers" },
{ id: "penny:dry-run", name: "PENNY: Dry run", icon: "eye" },
{ id: "penny:research", name: "PENNY: Research", icon: "search" },
{ id: "penny:status", name: "PENNY: Status", icon: "info" },
{ id: "penny:new-chapter", name: "PENNY: New chapter", icon: "file-plus" },
{ id: "penny:new-character", name: "PENNY: New character", icon: "user-plus" },
{ id: "penny:git-commit", name: "PENNY: Commit", icon: "git-commit-horizontal" },
{ id: "penny:git-push", name: "PENNY: Push", icon: "upload" },
];
/**
* Companion Plugins -- detect, describe, and auto-configure complementary plugins.
*/
private renderCompanionPluginsSection(containerEl: HTMLElement): void {
const details = containerEl.createEl("details");
details.createEl("summary", { text: "Companion Plugins" });
details.createEl("p", {
text: "These plugins enhance the PENNY workflow. Install them from the Community Plugins browser, then use the auto-configure button to set them up.",
cls: "setting-item-description",
});
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const plugins = (this.app as any).plugins?.manifests ?? {};
const companions: Array<{
id: string;
name: string;
desc: string;
icon: string;
url: string;
configure: ((card: HTMLElement) => void) | null;
}> = [
{
id: "slash-commander",
name: "Slash Commander",
desc: "Type / in the editor to access PENNY commands inline. Auto-configure adds all PENNY commands to your slash menu.",
icon: "terminal",
url: "obsidian://show-plugin?id=slash-commander",
configure: (card) => this.configureSlashCommander(card),
},
{
id: "editing-toolbar",
name: "Editing Toolbar",
desc: "Adds a floating toolbar with quick-access buttons. Auto-configure adds a PENNY submenu with processing and research commands.",
icon: "panel-top",
url: "obsidian://show-plugin?id=editing-toolbar",
configure: (card) => this.configureEditingToolbar(card),
},
{
id: "obsidian-git",
name: "Obsidian Git",
desc: "Automatic git backup and sync. Pairs well with PENNY's built-in git commands for version control of your prose.",
icon: "git-branch",
url: "obsidian://show-plugin?id=obsidian-git",
configure: null,
},
];
const grid = details.createDiv({ cls: "penny-companion-grid" });
for (const c of companions) {
const installed = c.id in plugins;
const card = grid.createDiv({ cls: `penny-companion-card ${installed ? "" : "penny-companion-dimmed"}` });
// Header row: icon + name + status badge
const header = card.createDiv({ cls: "penny-companion-header" });
const titleWrap = header.createDiv({ cls: "penny-companion-title-wrap" });
titleWrap.createSpan({ cls: "penny-companion-name", text: c.name });
titleWrap.createSpan({
cls: `penny-companion-badge ${installed ? "penny-badge-installed" : "penny-badge-missing"}`,
text: installed ? "Installed" : "Not installed",
});
// Description
card.createEl("p", { cls: "penny-companion-desc", text: c.desc });
// Action buttons
const actions = card.createDiv({ cls: "penny-companion-actions" });
if (installed && c.configure) {
const configBtn = actions.createEl("button", { text: "Auto-configure", cls: "mod-cta" });
configBtn.addEventListener("click", () => c.configure!(card));
}
if (!installed) {
const installLink = actions.createEl("a", {
text: "Install from Community Plugins",
cls: "penny-companion-link",
href: c.url,
});
installLink.addEventListener("click", (e) => {
e.preventDefault();
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(window as any).open(c.url);
});
}
}
}
/**
* Write PENNY slash commands into Slash Commander's config.
*/
private async configureSlashCommander(card: HTMLElement): Promise<void> {
try {
const configPath = `${this.app.vault.configDir}/plugins/slash-commander/data.json`;
const raw = await this.app.vault.adapter.read(configPath);
const config = JSON.parse(raw);
if (!Array.isArray(config.bindings)) {
config.bindings = [];
}
// Remove existing PENNY bindings to avoid duplicates
config.bindings = config.bindings.filter(
(b: { action?: string }) => !b.action?.startsWith("penny:")
);
// Add PENNY commands
for (const cmd of PennySettingTab.PENNY_COMMANDS) {
config.bindings.push({
name: cmd.name,
id: cmd.id,
action: cmd.id,
icon: cmd.icon,
mode: "editing",
triggerMode: "anywhere",
});
}
await this.app.vault.adapter.write(configPath, JSON.stringify(config, null, 2));
new Notice("PENNY commands added to Slash Commander. Reload the plugin to apply.");
// Show success inline
const msg = card.createDiv({ cls: "penny-companion-success" });
msg.setText("Configured! Reload Slash Commander to apply.");
} catch (e) {
new Notice(`Failed to configure Slash Commander: ${e instanceof Error ? e.message : String(e)}`);
}
}
/**
* Write PENNY toolbar submenu into Editing Toolbar's config.
*/
private async configureEditingToolbar(card: HTMLElement): Promise<void> {
try {
const configPath = `${this.app.vault.configDir}/plugins/editing-toolbar/data.json`;
const raw = await this.app.vault.adapter.read(configPath);
const config = JSON.parse(raw);
if (!Array.isArray(config.menuCommands)) {
config.menuCommands = [];
}
// Remove existing PENNY submenu to avoid duplicates
config.menuCommands = config.menuCommands.filter(
(c: { id?: string }) => c.id !== "SubmenuCommands-penny"
);
// Build PENNY submenu
const pennySubmenu = {
id: "SubmenuCommands-penny",
name: "PENNY",
icon: "pen-tool",
SubmenuCommands: PennySettingTab.PENNY_COMMANDS.map((cmd) => ({
id: cmd.id,
name: cmd.name,
icon: cmd.icon,
})),
};
config.menuCommands.push(pennySubmenu);
await this.app.vault.adapter.write(configPath, JSON.stringify(config, null, 2));
new Notice("PENNY submenu added to Editing Toolbar. Reload the plugin to apply.");
const msg = card.createDiv({ cls: "penny-companion-success" });
msg.setText("Configured! Reload Editing Toolbar to apply.");
} catch (e) {
new Notice(`Failed to configure Editing Toolbar: ${e instanceof Error ? e.message : String(e)}`);
}
tip.appendText("Press Cmd/Ctrl+P and type \"PENNY\" to see all commands.");
}
}
-327
View File
@@ -1,327 +0,0 @@
/**
* PENNY - First-Run Setup Wizard
*
* A multi-step modal that guides new users through initial configuration.
* Triggered on first load when no provider API key is configured.
* Steps: Welcome -> Provider -> Project Structure -> Done.
*/
import { Modal, Setting, TFolder } from "obsidian";
import type PennyPlugin from "./main";
import type { PennySettings } from "./types";
type Step = "welcome" | "provider" | "structure" | "done";
const STEPS: Step[] = ["welcome", "provider", "structure", "done"];
const STEP_TITLES: Record<Step, string> = {
welcome: "Welcome to PENNY",
provider: "Choose Your LLM Provider",
structure: "Project Structure",
done: "You're All Set",
};
export class SetupWizardModal extends Modal {
private plugin: PennyPlugin;
private currentStep: Step = "welcome";
private selectedProvider = "anthropic";
constructor(plugin: PennyPlugin) {
super(plugin.app);
this.plugin = plugin;
}
onOpen(): void {
this.modalEl.addClass("penny-wizard-modal");
this.render();
}
onClose(): void {
this.contentEl.empty();
}
private render(): void {
const { contentEl } = this;
contentEl.empty();
// Progress indicator
const progress = contentEl.createDiv({ cls: "penny-wizard-progress" });
const stepIdx = STEPS.indexOf(this.currentStep);
for (let i = 0; i < STEPS.length; i++) {
const dot = progress.createSpan({
cls: `penny-wizard-dot ${i === stepIdx ? "penny-wizard-dot-active" : ""} ${i < stepIdx ? "penny-wizard-dot-done" : ""}`,
});
dot.setText(String(i + 1));
if (i < STEPS.length - 1) {
progress.createSpan({
cls: `penny-wizard-line ${i < stepIdx ? "penny-wizard-line-done" : ""}`,
});
}
}
// Step title
contentEl.createEl("h2", {
text: STEP_TITLES[this.currentStep],
cls: "penny-wizard-title",
});
// Step content
const body = contentEl.createDiv({ cls: "penny-wizard-body" });
switch (this.currentStep) {
case "welcome": this.renderWelcome(body); break;
case "provider": this.renderProvider(body); break;
case "structure": this.renderStructure(body); break;
case "done": this.renderDone(body); break;
}
// Navigation buttons
const nav = contentEl.createDiv({ cls: "penny-wizard-nav" });
if (stepIdx > 0 && this.currentStep !== "done") {
const backBtn = nav.createEl("button", { text: "Back" });
backBtn.addEventListener("click", () => {
this.currentStep = STEPS[stepIdx - 1];
this.render();
});
} else {
nav.createDiv(); // spacer
}
if (this.currentStep === "done") {
const doneBtn = nav.createEl("button", { text: "Start Writing", cls: "mod-cta" });
doneBtn.addEventListener("click", () => this.close());
} else {
const skipBtn = nav.createEl("button", { text: "Skip Setup" });
skipBtn.addEventListener("click", () => {
this.plugin.settings.setupComplete = true;
this.plugin.saveSettings();
this.close();
});
const nextBtn = nav.createEl("button", { text: "Next", cls: "mod-cta" });
nextBtn.addEventListener("click", () => {
this.currentStep = STEPS[stepIdx + 1];
this.render();
});
}
}
private renderWelcome(container: HTMLElement): void {
container.createEl("p", {
text: "PENNY is your AI prose co-author. It reads editorial annotations in your chapters, processes them through an LLM with your project's voice rules and context, and writes revised versions.",
});
const steps = container.createEl("div", { cls: "penny-wizard-checklist" });
const items = [
["1", "Connect an LLM provider", "Anthropic Claude, OpenAI, Google Gemini, or local Ollama"],
["2", "Set your project folders", "Where your drafts, characters, and style guide live"],
["3", "Start annotating", "Add %% REWRITE: instructions %% to your prose and let PENNY revise"],
];
for (const [num, title, desc] of items) {
const item = steps.createDiv({ cls: "penny-wizard-check-item" });
item.createSpan({ cls: "penny-wizard-check-num", text: num });
const text = item.createDiv();
text.createEl("strong", { text: title });
text.createEl("span", { text: " -- " + desc, cls: "setting-item-description" });
}
}
private renderProvider(container: HTMLElement): void {
container.createEl("p", {
text: "Which LLM provider do you want to use? You can add more later in Settings.",
cls: "setting-item-description",
});
const providers = [
{ id: "anthropic", name: "Anthropic Claude", desc: "Best for prose. Recommended.", badge: "Recommended" },
{ id: "openai", name: "OpenAI", desc: "GPT-4.1 and o3 models.", badge: "" },
{ id: "google", name: "Google Gemini", desc: "Gemini 2.5 Pro and Flash.", badge: "" },
{ id: "ollama", name: "Ollama (Local)", desc: "Free, runs on your machine. Requires Ollama installed.", badge: "Free" },
];
const grid = container.createDiv({ cls: "penny-wizard-provider-grid" });
for (const p of providers) {
const card = grid.createDiv({
cls: `penny-wizard-provider-card ${this.selectedProvider === p.id ? "penny-wizard-provider-selected" : ""}`,
});
card.addEventListener("click", () => {
this.selectedProvider = p.id;
this.render();
});
const header = card.createDiv({ cls: "penny-wizard-provider-header" });
header.createSpan({ text: p.name, cls: "penny-wizard-provider-name" });
if (p.badge) {
header.createSpan({ text: p.badge, cls: "penny-wizard-provider-badge" });
}
card.createEl("span", { text: p.desc, cls: "setting-item-description" });
}
// API key input for the selected provider
if (this.selectedProvider !== "ollama") {
const keyLabel = this.selectedProvider === "anthropic" ? "Anthropic"
: this.selectedProvider === "openai" ? "OpenAI"
: "Google";
new Setting(container)
.setName(`${keyLabel} API key`)
.setDesc("Stored locally on your device. Never sent anywhere except the provider's API.")
.addText((text) =>
text
.setPlaceholder(`Paste your ${keyLabel} API key`)
.setValue(this.getApiKey())
.then((t) => {
t.inputEl.type = "password";
t.inputEl.style.width = "350px";
})
.onChange(async (value) => {
this.setApiKey(value.trim());
await this.plugin.saveSettings();
})
);
} else {
new Setting(container)
.setName("Ollama endpoint")
.setDesc("URL of your local Ollama instance. Default works if Ollama is running.")
.addText((text) =>
text
.setPlaceholder("http://localhost:11434")
.setValue(this.plugin.settings.ollamaEndpoint)
.then((t) => { t.inputEl.style.width = "350px"; })
.onChange(async (value) => {
this.plugin.settings.ollamaEndpoint = value.trim();
await this.plugin.saveSettings();
})
);
}
}
private renderStructure(container: HTMLElement): void {
container.createEl("p", {
text: "Where are your project files? PENNY uses these paths to load context (characters, style guide, plot outlines) when revising your prose.",
cls: "setting-item-description",
});
// Auto-detect common structures
const detected = this.detectFolders();
if (detected.length > 0) {
const tip = container.createDiv({ cls: "penny-wizard-detected" });
tip.createEl("strong", { text: "Detected project folders:" });
const list = tip.createEl("ul");
for (const d of detected) {
list.createEl("li", { text: `${d.label}: ${d.path}` });
}
}
const paths: Array<{ key: keyof PennySettings; label: string; placeholder: string }> = [
{ key: "draftsFolder", label: "Drafts folder", placeholder: "04-drafts" },
{ key: "characterSheetsFolder", label: "Characters folder", placeholder: "02-characters" },
{ key: "styleGuide", label: "Style guide", placeholder: "06-reference/style-guide.md" },
{ key: "plotOutlinesFolder", label: "Plot outlines", placeholder: "03-plot" },
];
for (const p of paths) {
new Setting(container)
.setName(p.label)
.addText((text) =>
text
.setPlaceholder(p.placeholder)
.setValue(String(this.plugin.settings[p.key] || ""))
.then((t) => { t.inputEl.style.width = "300px"; })
.onChange(async (value) => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(this.plugin.settings as any)[p.key] = value.trim();
await this.plugin.saveSettings();
})
);
}
container.createEl("p", {
text: "You can change all paths later in Settings > PENNY > Project Structure.",
cls: "setting-item-description",
attr: { style: "margin-top: 12px; font-style: italic;" },
});
}
private renderDone(container: HTMLElement): void {
const summary = container.createDiv({ cls: "penny-wizard-summary" });
const providerName = this.selectedProvider === "anthropic" ? "Anthropic Claude"
: this.selectedProvider === "openai" ? "OpenAI"
: this.selectedProvider === "google" ? "Google Gemini"
: "Ollama (local)";
const hasKey = this.selectedProvider === "ollama" || this.getApiKey().length > 0;
summary.createEl("p", { text: `Provider: ${providerName} ${hasKey ? "" : "(no API key set yet)"}` });
container.createEl("h3", { text: "Quick Start" });
const tips = container.createEl("div", { cls: "penny-wizard-checklist" });
const quickSteps = [
["1", "Open a chapter file", "Any .md file in your drafts folder"],
["2", "Add an annotation", "Type: %% REWRITE: your instruction here %%"],
["3", "Run PENNY", "Cmd/Ctrl+Shift+P or right-click > PENNY: Process this chapter"],
["4", "Check the output", "PENNY creates a new version file with the revision"],
];
for (const [num, title, desc] of quickSteps) {
const item = tips.createDiv({ cls: "penny-wizard-check-item" });
item.createSpan({ cls: "penny-wizard-check-num", text: num });
const text = item.createDiv();
text.createEl("strong", { text: title });
text.createEl("span", { text: " -- " + desc, cls: "setting-item-description" });
}
container.createEl("p", {
text: "For multi-paragraph targets, wrap text in {{ }} markers. See Commands Reference in settings for all annotation types.",
cls: "setting-item-description",
attr: { style: "margin-top: 12px;" },
});
// Mark setup as complete
this.plugin.settings.setupComplete = true;
this.plugin.saveSettings();
}
private getApiKey(): string {
switch (this.selectedProvider) {
case "anthropic": return this.plugin.settings.anthropicApiKey;
case "openai": return this.plugin.settings.openaiApiKey;
case "google": return this.plugin.settings.googleApiKey;
default: return "";
}
}
private setApiKey(value: string): void {
switch (this.selectedProvider) {
case "anthropic": this.plugin.settings.anthropicApiKey = value; break;
case "openai": this.plugin.settings.openaiApiKey = value; break;
case "google": this.plugin.settings.googleApiKey = value; break;
}
}
private detectFolders(): Array<{ label: string; path: string }> {
const found: Array<{ label: string; path: string }> = [];
const check = (path: string, label: string) => {
const f = this.app.vault.getAbstractFileByPath(path);
if (f instanceof TFolder) {
found.push({ label, path });
}
};
check("04-drafts", "Drafts");
check("02-characters", "Characters");
check("03-plot", "Plot outlines");
check("06-reference", "Reference");
check("01-world", "World-building");
check("05-wiki", "Wiki");
// Also check inside series subdirectories
const root = this.app.vault.getRoot();
if (root.children) {
for (const child of root.children) {
if (child instanceof TFolder) {
check(`${child.name}/04-drafts`, `Drafts (${child.name})`);
check(`${child.name}/02-characters`, `Characters (${child.name})`);
}
}
}
return found;
}
}
+2 -11
View File
@@ -1,17 +1,8 @@
/**
* PENNY - Status Bar Widget
*
* Manages a status bar element in Obsidian's footer that shows
* contextual information about the active file. Created by main.ts
* at plugin load and updated on every active-leaf-change event.
*
* Three display states:
* - "ready": default idle state ("PENNY: ready")
* - "info": showing annotation count + chapter info for the active file
* - "processing": locked to "PENNY: processing..." during LLM calls
*
* Uses globMatch() from utils.ts for chapter file detection to avoid
* constructing RegExp from user input (ReDoS prevention).
* Manages the status bar element in Obsidian's footer.
* Shows annotation counts, active chapter info, and processing state.
*/
import type { TFile } from "obsidian";
+16 -293
View File
@@ -1,170 +1,60 @@
/**
* PENNY - Prose Engine for Narrative, Notes, and Yarns
* Shared type definitions and constants.
*
* Central type authority for the entire plugin. Every module imports its
* domain types from here rather than defining them locally, ensuring a
* single source of truth. Also exports default settings, the default
* system prompt template, and the canonical list of annotation tags.
*
* This file has NO runtime dependencies on Obsidian or any provider,
* so it can be imported freely in pure-logic modules and tests.
* Shared type definitions
*/
/**
* Log level for controlling console output verbosity.
* Ordered from most to least verbose: debug > info > warn > error > off.
* "off" suppresses all PENNY console output.
*/
export type LogLevel = "debug" | "info" | "warn" | "error" | "off";
/**
* Provider + model pair assigned to a single complexity tier.
* Three of these (light, standard, heavy) make up a full RouteConfig.
*
* @see RouteConfig in providers/router.ts
*/
/** Route configuration for a complexity tier */
export interface ModelRoute {
/** Provider name as registered in the ProviderRegistry (e.g. "anthropic", "ollama"). */
provider: string;
/** Model identifier understood by the provider, or "auto-latest" for automatic resolution. */
model: string;
}
/**
* Plugin settings persisted in Obsidian's data.json.
*
* Loaded at startup by main.ts and merged with DEFAULT_SETTINGS.
* The settings tab (settings.ts) reads and writes these fields.
* Per-project overrides from PENNY.md can shadow a subset of these
* (see project-config.ts).
*/
/** Plugin settings stored in Obsidian's data.json */
export interface PennySettings {
/** Whether the first-run setup wizard has been completed or skipped. */
setupComplete: boolean;
// Provider keys & endpoints
/** Anthropic API key (stored locally, never transmitted except to Anthropic). */
anthropicApiKey: string;
/** Base URL for the Ollama instance (e.g. "http://localhost:11434"). */
ollamaEndpoint: string;
/** Optional API key for authenticated Ollama instances (e.g. remote hosted). */
ollamaApiKey: string;
/** Google Gemini API key (stored locally, never transmitted except to Google). */
googleApiKey: string;
/** OpenAI API key (stored locally, never transmitted except to OpenAI). */
openaiApiKey: string;
// Model routing
/** Model route for lightweight tasks (CUT, PACING). */
routeLight: ModelRoute;
/** Model route for standard tasks (TONE, EXPAND, PLOT). */
routeStandard: ModelRoute;
/** Model route for complex tasks (REWRITE, DIALOG, CHARACTER). */
routeHeavy: ModelRoute;
/** Model route for research queries (PENNY: Do research command). */
routeResearch: ModelRoute;
/** When true, all tiers use the routeStandard model, ignoring light/heavy. */
useSameModelForAll: boolean;
// Context
/** Max tokens to assemble for context (chapter + reference material). */
contextBudget: number;
/** Max tokens the LLM may generate per revision response. */
maxTokens: number;
/**
* Per-call inactivity timeout in milliseconds. Prevents the modal from
* hanging forever on a stalled connection. Default 300_000 (5 minutes) to
* accommodate slow Opus calls with long context. Set to 0 to disable.
*/
requestTimeoutMs: number;
/**
* Maximum retries on top of the initial LLM call. `maxRetries: 3` means up
* to 4 total attempts. Set to 0 to disable retry. Applies to 429, 408, 5xx,
* and transport errors. Default 3.
*/
maxRetries: number;
// Project structure (paths relative to vault root)
/** Folder containing chapter draft files, organized by book subfolder. */
draftsFolder: string;
/** Path to the style guide markdown file. */
styleGuide: string;
/** Path to the voice tests / locked examples file. */
voiceTests: string;
/** Folder containing character profile markdown files. */
characterSheetsFolder: string;
/** Folder containing per-book plot outlines. */
plotOutlinesFolder: string;
/** Folder containing wiki/lore entries. */
wikiFolder: string;
/** Path to the series bible file. */
seriesBible: string;
/** Path to the themes file. */
themesFile: string;
/** Folder where PENNY writes review notes after processing. */
reviewsFolder: string;
/** Folder where PENNY writes JSONL activity logs. */
activityLogFolder: string;
// Voice
/** Project-specific voice rules injected into the CRITICAL VOICE RULES prompt section. */
customVoiceRules: string;
/** Full system prompt template with {placeholder} tokens for context sections. */
systemPromptTemplate: string;
// Behavior
/** Automatically process annotations when a chapter file is saved. */
autoProcessOnSave: boolean;
/** Controls console log verbosity. */
logLevel: LogLevel;
/** Glob pattern identifying chapter files within book folders (e.g. "ch-*.md"). */
verboseLogging: boolean;
chapterFilePattern: string;
/** HTML comment that separates outline/frontmatter from prose for word counting. */
proseMarker: string;
// Research
/** Folder where the research command saves notes. */
researchFolder: string;
// Progress & Notifications
/** Show a progress modal during annotation processing. */
showProgressModal: boolean;
/** Show Obsidian Notice popups for status updates (used when modal is minimized). */
showStatusNotices: boolean;
// UI
/** Show PENNY ribbon icon (pen icon in left sidebar). */
showRibbonIcon: boolean;
/** Show PENNY items in the editor right-click context menu. */
showContextMenu: boolean;
/** Show PENNY status in the bottom status bar. */
showStatusBar: boolean;
// Git
/** Stage and commit changes automatically after PENNY processes annotations. */
autoCommitAfterProcessing: boolean;
/** Push to remote automatically after auto-commit (requires autoCommitAfterProcessing). */
autoPushAfterCommit: boolean;
/** Template for auto-generated commit messages. Placeholders: {chapter}, {version}, {tags}. */
commitMessageFormat: string;
}
/**
* The default system prompt template with {placeholder} tokens.
*
* Placeholders are replaced at prompt-build time by drafter.ts:
* - {voice_rules} -- customVoiceRules from settings / PENNY.md
* - {voice_tests} -- locked voice examples
* - {style_guide} -- project style guide
* - {outline} -- book plot outline
* - {characters} -- character sheets for characters in scene
* - {wiki} -- relevant wiki/lore entries
* - {chapter} -- full current chapter text
*
* Sections whose context is empty are removed entirely (heading + placeholder)
* to keep the prompt clean.
*/
/** The default system prompt template with placeholders */
export const DEFAULT_SYSTEM_PROMPT = `You are PENNY, a fiction co-author. You are revising a chapter based on the author's editorial annotation. Match the project's voice exactly.
{voice_rules}
@@ -184,12 +74,6 @@ export const DEFAULT_SYSTEM_PROMPT = `You are PENNY, a fiction co-author. You ar
## Wiki/Lore References
{wiki}
## Series Bible
{series_bible}
## Themes
{themes}
---
## Current Chapter (Full Text)
@@ -209,9 +93,6 @@ Write the revised passage. Rules:
*
* If saved data contains old `apiKey` or `model` fields, map them to the
* new `anthropicApiKey` and route fields so existing users are not broken.
*
* @param saved - Raw settings object loaded from Obsidian's data.json
* @returns A new object with legacy fields mapped to their modern equivalents
*/
export function migrateSettings(
saved: Record<string, unknown>,
@@ -235,37 +116,23 @@ export function migrateSettings(
}
delete migrated.model;
// Migrate old verboseLogging boolean -> logLevel
if ("verboseLogging" in migrated && !("logLevel" in migrated)) {
migrated.logLevel = migrated.verboseLogging ? "debug" : "info";
}
delete migrated.verboseLogging;
return migrated;
}
/** Sensible defaults for every setting. Merged with persisted data at load time. */
export const DEFAULT_SETTINGS: PennySettings = {
setupComplete: false,
anthropicApiKey: "",
ollamaEndpoint: "http://localhost:11434",
ollamaApiKey: "",
googleApiKey: "",
openaiApiKey: "",
// "auto-latest" resolves at runtime to the best model for each tier.
// See resolveModel() in providers/router.ts.
routeLight: { provider: "anthropic", model: "auto-latest" },
routeStandard: { provider: "anthropic", model: "auto-latest" },
routeHeavy: { provider: "anthropic", model: "auto-latest" },
routeResearch: { provider: "anthropic", model: "auto-latest" },
useSameModelForAll: false,
contextBudget: 800000,
maxTokens: 16000,
requestTimeoutMs: 300000,
maxRetries: 3,
draftsFolder: "04-drafts",
styleGuide: "",
@@ -282,28 +149,16 @@ export const DEFAULT_SETTINGS: PennySettings = {
systemPromptTemplate: DEFAULT_SYSTEM_PROMPT,
autoProcessOnSave: false,
logLevel: "debug",
verboseLogging: false,
chapterFilePattern: "ch-*.md",
proseMarker: "<!-- Prose begins below -->",
researchFolder: "06-reference/research",
showProgressModal: true,
showStatusNotices: true,
showRibbonIcon: true,
showContextMenu: true,
showStatusBar: true,
autoCommitAfterProcessing: false,
autoPushAfterCommit: false,
commitMessageFormat: "docs({chapter}): PENNY v{version} - {tags}",
};
/**
* Tags that trigger LLM processing. Each annotation with one of these tags
* produces an API call to revise the targeted passage.
*/
/** Supported annotation tags */
export const ACTIONABLE_TAGS = [
"REWRITE",
"EXPAND",
@@ -315,114 +170,55 @@ export const ACTIONABLE_TAGS = [
"CHARACTER",
] as const;
/**
* Tags that are preserved in the chapter but never sent to the LLM.
* Used for author notes and research flags that should survive processing.
*/
export const PASSTHROUGH_TAGS = ["NOTE", "RESEARCH"] as const;
/** Union of all recognized annotation tags (actionable + passthrough). */
export const ALL_TAGS = [...ACTIONABLE_TAGS, ...PASSTHROUGH_TAGS] as const;
/** Type for tags that trigger LLM revision. */
export type ActionableTag = (typeof ACTIONABLE_TAGS)[number];
/** Type for tags preserved without processing. */
export type PassthroughTag = (typeof PASSTHROUGH_TAGS)[number];
/** Union type of every valid annotation tag. */
export type AnnotationTag = (typeof ALL_TAGS)[number];
/**
* A single parsed annotation extracted from chapter markdown.
*
* Created by parser.ts from `%% TAG: instruction %%` patterns.
* Consumed by the pipeline to drive per-annotation LLM calls and
* by the assembler to splice revised text back into the chapter.
*/
/** Parsed annotation from a chapter file */
export interface AnnotatedSection {
/** The annotation tag (e.g. "REWRITE", "NOTE"). */
tag: AnnotationTag;
/** The author's instruction text after the tag. */
instruction: string;
/** The prose passage this annotation targets (with annotation markers stripped). */
originalText: string;
/** Zero-based line index where the targeted passage begins. */
lineStart: number;
/** Zero-based line index where the targeted passage ends (inclusive). */
lineEnd: number;
/** How the annotation's target was determined: inline text, surrounding paragraph, heading section, or explicit {{ }} block. */
scope: "inline" | "paragraph" | "section" | "block";
/** True if this tag triggers LLM processing; false for passthrough tags. */
scope: "inline" | "paragraph" | "section";
actionable: boolean;
/** Deterministic hash of (tag, instruction, originalText) for idempotency tracking. */
hash: string;
}
/**
* Persisted state for a chapter folder, stored in `.state.json`.
*
* Tracks which annotations have been processed to provide idempotency:
* running PENNY twice with the same annotations will not re-process them.
*/
/** Chapter version state */
export interface VersionState {
/** Current version number for this chapter. */
version: number;
/** ISO timestamp of the last processing pass, or null if never processed. */
lastProcessed: string | null;
/** History of all annotations that have been processed, keyed by hash. */
processedAnnotations: ProcessedAnnotation[];
}
/**
* Record of a single annotation that has been processed.
* Stored in VersionState.processedAnnotations for idempotency checks.
*/
export interface ProcessedAnnotation {
/** Deterministic hash identifying the annotation content. */
hash: string;
/** The tag that was processed (e.g. "REWRITE"). */
tag: string;
/** The line number where the annotation appeared at processing time. */
line: number;
/** The version number in which this annotation was processed. */
processedInVersion: number;
}
/**
* All context material assembled for a single LLM revision call.
*
* Built by context.ts with token-budget awareness: lower-priority
* material is dropped when the budget is exceeded.
*/
/** Context assembled for a revision prompt */
export interface AssembledContext {
/** Full current chapter text (always included, never truncated). */
chapter: string;
/** Voice test examples, possibly filtered to scene-relevant characters. */
voiceTests: string;
/** Project style guide content. */
styleGuide: string;
/** Book plot outline content. */
outline: string;
/** Concatenated character sheets for characters detected in the scene. */
characters: string;
/** Concatenated wiki/lore entries. */
wiki: string;
/** Series bible content (included only if budget permits). */
seriesBible: string;
/** Themes file content (included only if budget permits). */
themes: string;
/** Custom voice rules from settings or PENNY.md. */
voiceRules: string;
/** Estimated total token count of all assembled context. */
totalTokenEstimate: number;
}
/**
* YAML frontmatter fields for a chapter file.
*
* Author-managed fields (type through act) are set by the author.
* Agent-managed fields (agent_*) are updated automatically by PENNY
* after each processing pass.
*/
/** Chapter frontmatter */
export interface ChapterFrontmatter {
type?: string;
book?: number;
@@ -431,152 +227,79 @@ export interface ChapterFrontmatter {
pov?: string;
status?: string;
wordcount?: number;
/** Comma-separated character names for voice test filtering. */
focus?: string;
/** Which narrative thread this chapter advances. */
thread?: string;
act?: number;
// Agent-managed fields (updated by pipeline.ts after processing)
/** Version number set by PENNY after creating a new version. */
// Agent-managed
agent_version?: number;
/** ISO timestamp of the last PENNY revision. */
agent_last_revised?: string;
/** Count of actionable annotations remaining after processing. */
agent_annotations_pending?: number;
/** Characters detected in the scene via dialogue attribution patterns. */
characters_in_scene?: string[];
/** Allow arbitrary additional frontmatter keys. */
[key: string]: unknown;
}
/**
* Complete data for a review note generated after a processing pass.
* Passed to reviewer.ts to produce the markdown review file.
*/
/** Review note data */
export interface ReviewData {
/** ISO timestamp when processing completed. */
timestamp: string;
/** Book identifier (e.g. "book-1"). */
book: string;
/** Chapter identifier (e.g. "ch-05"). */
chapter: string;
/** The version number that was created. */
version: number;
/** Number of annotations that were sent to the LLM. */
annotationsProcessed: number;
/** Number of passthrough annotations that were preserved. */
annotationsSkipped: number;
/** Per-annotation change details. */
changes: AnnotationChange[];
/** Issues flagged for author attention. */
flags: ReviewFlag[];
/** Voice compliance metrics for the new version. */
voiceCompliance: VoiceComplianceResult;
/** Word count before processing. */
wordCountBefore: number;
/** Word count after processing. */
wordCountAfter: number;
}
/**
* Details about a single annotation that was processed by the LLM.
* One entry per actionable annotation in the processing pass.
*/
export interface AnnotationChange {
/** The annotation tag. */
tag: string;
/** The author's instruction. */
instruction: string;
/** Line where the annotation appeared. */
lineStart: number;
/** Word count of the original passage. */
originalWordCount: number;
/** Word count of the LLM-produced replacement. */
revisedWordCount: number;
/** Which context files were included in the prompt. */
contextFilesUsed: string[];
/** Provider that handled this annotation (e.g. "anthropic"). */
provider: string;
/** Model that produced the revision (e.g. "claude-sonnet-4-6"). */
model: string;
}
/**
* A flag raised during processing that needs author attention.
* Included in the review note under "Flags for Author".
*/
export interface ReviewFlag {
/** Category of the flag. */
type: "research" | "plot" | "vague" | "error";
/** Line number associated with the flag. */
line: number;
/** Human-readable description of what needs attention. */
description: string;
}
/**
* Voice compliance metrics computed by voice-check.ts.
* Measures how well the prose adheres to the project's voice rules.
*/
export interface VoiceComplianceResult {
/** Ratio of dialogue words to total words (0.0 to 1.0). */
dialogueRatio: number;
/** Narration sentences exceeding the max word threshold. */
longNarrationSentences: string[];
/** Lines matching self-analysis / internal monologue patterns. */
selfAnalysisFlags: string[];
}
/**
* Single entry in the JSONL activity log.
* One entry per processing pass, appended to {book}-activity.jsonl.
*/
/** Activity log entry */
export interface ActivityLogEntry {
/** ISO timestamp of the processing pass. */
timestamp: string;
/** Book identifier. */
book: string;
/** Chapter identifier. */
chapter: string;
/** Version number before processing. */
versionFrom: number;
/** Version number after processing. */
versionTo: number;
/** Number of annotations sent to the LLM. */
annotationsProcessed: number;
/** Number of passthrough annotations preserved. */
annotationsSkipped: number;
/** Prose word count before processing. */
wordCountBefore: number;
/** Prose word count after processing. */
wordCountAfter: number;
/** Tags that were processed in this pass. */
tags: string[];
/** Wall-clock duration of the processing pass in milliseconds. */
durationMs: number;
/** Primary provider used. */
provider: string;
/** Primary model used. */
model: string;
/** Voice compliance check results for the new version. */
voiceCompliance: VoiceComplianceResult;
}
/**
* Options for the project initialization wizard (ProjectInitModal in commands.ts).
* Drives folder and file scaffolding for a new novel project.
*/
/** Project init wizard options */
export interface ProjectInitOptions {
/** Root folder name for the project (becomes a top-level vault directory). */
projectName: string;
/** How many books to scaffold (each gets its own plot/drafts/reviews subfolders). */
numberOfBooks: number;
/** How many chapter folders to create per book. */
chaptersPerBook: number;
/** Whether to create a templates/ folder with starter templates. */
includeTemplates: boolean;
/** Narrative point of view for the style guide. */
pov: "first" | "third";
/** Narrative tense for the style guide. */
tense: "past" | "present";
}
-4
View File
@@ -2,10 +2,6 @@
* PENNY - Shared Utilities
*
* Common utility functions used across multiple modules.
* Currently provides glob matching for chapter file pattern detection.
*
* Used by: statusbar.ts, commands.ts, main.ts to check whether a file
* matches the user's configured chapterFilePattern.
*/
/**
+6 -33
View File
@@ -1,16 +1,7 @@
/**
* PENNY - Version Management
*
* Manages the `.version` manifest and `.state.json` files that live
* inside each chapter folder. These two files provide:
*
* - `.version`: A plain integer -- the current version number.
* - `.state.json`: A VersionState object tracking which annotations
* have been processed (by hash) to ensure idempotency.
*
* Called by pipeline.ts to read current state, filter already-processed
* annotations, and write updated state after a processing pass.
*
* Manages .version manifest and .state.json files.
* Pure functions -- no Obsidian API dependencies.
*/
@@ -18,9 +9,7 @@ import type { AnnotatedSection, VersionState, ProcessedAnnotation } from "./type
/**
* Read the current version number from .version file content.
*
* @param versionContent - Raw text content of the `.version` file
* @returns The parsed version number, or 0 if empty/unparseable/negative
* Returns 0 if the content is empty or unparseable.
*/
export function readVersion(versionContent: string): number {
const trimmed = versionContent.trim();
@@ -29,21 +18,15 @@ export function readVersion(versionContent: string): number {
}
/**
* Compute the next version number (simple increment).
*
* @param current - The current version number
* @returns current + 1
* Compute the next version number.
*/
export function nextVersion(current: number): number {
return current + 1;
}
/**
* Parse `.state.json` content into a VersionState.
* Defensively validates every field to handle corrupted or hand-edited files.
*
* @param stateContent - Raw JSON string from the `.state.json` file
* @returns A valid VersionState (defaults to version 0 / empty history if parsing fails)
* Parse .state.json content into a VersionState.
* Returns a default state if the content is empty or invalid JSON.
*/
export function readState(stateContent: string): VersionState {
const defaultState: VersionState = {
@@ -118,13 +101,6 @@ export function updateState(
* Filter annotations to only those not yet processed (by hash).
* This provides idempotency: running PENNY twice on the same chapter
* with the same annotations will not re-process them.
*
* Also filters out passthrough tags (NOTE, RESEARCH) which are never
* sent to the LLM regardless of processing history.
*
* @param annotations - All parsed annotations from the chapter
* @param state - Current version state with processing history
* @returns Only the actionable annotations whose hashes are not in state
*/
export function shouldProcess(
annotations: AnnotatedSection[],
@@ -135,10 +111,7 @@ export function shouldProcess(
}
/**
* Serialize a VersionState to pretty-printed JSON for writing to `.state.json`.
*
* @param state - The version state to serialize
* @returns JSON string with 2-space indentation
* Serialize a VersionState to JSON for writing to .state.json.
*/
export function serializeState(state: VersionState): string {
return JSON.stringify(state, null, 2);
+12 -54
View File
@@ -1,29 +1,17 @@
/**
* PENNY - Voice Compliance
*
* Analyzes prose for adherence to the project's voice rules by computing
* three metrics:
* 1. Dialogue-to-narration ratio (how much of the text is spoken dialogue)
* 2. Long narration sentences (narration sentences exceeding a word threshold)
* 3. Self-analysis flags (lines matching internal monologue / "she thought" patterns)
*
* Called by pipeline.ts after a new version is assembled. Results are
* included in the review note and activity log so the author can spot
* voice drift.
*
* Analyze prose for voice compliance: dialogue ratio, long narration
* sentences, and self-analysis patterns.
* Pure function -- no Obsidian API dependencies.
*/
import type { VoiceComplianceResult } from "./types";
/** Maximum allowed words in a narration sentence before flagging it as "long". */
/** Maximum allowed words in a narration sentence before flagging. */
const MAX_NARRATION_WORDS = 20;
/**
* Regex patterns that indicate self-analysis / internal monologue.
* These are voice violations for projects that forbid narrated inner thought
* (e.g. the protagonist who only thinks by talking aloud).
*/
/** Patterns that indicate self-analysis / internal monologue. */
const SELF_ANALYSIS_PATTERNS: RegExp[] = [
/\bmy brain\b/i,
/\bI feel\b/i,
@@ -58,50 +46,20 @@ function splitSentences(text: string): string[] {
}
/**
* Patterns that match dialogue spans across the supported quote styles
* (issue #5):
*
* - Straight double quotes (`"..."`)
* - Paired typographic double quotes (U+201C ... U+201D)
* - Single-quoted dialogue (British style `'...'`). Opener anchored at line
* start or after whitespace; closer followed by sentence punctuation,
* whitespace, or end-of-line. Apostrophes inside contractions don't qualify
* because they are mid-word, not at quote boundaries.
* - Paired typographic single quotes (U+2018 ... U+2019)
*
* Each pattern captures the dialogue text in group 1.
*/
const DIALOGUE_PATTERNS: readonly RegExp[] = [
/"([^"\n]+?)"/g,
/\u201C([^\u201D\n]+?)\u201D/g,
/(?:^|\s)'([^'\n]+?)'(?=[\s.!?,;:]|$)/gm,
/\u2018([^\u2019\n]+?)\u2019/g,
];
/**
* Extract dialogue word count from prose. Counts spoken text across all
* supported quote styles.
* Extract dialogue word count from prose.
* Dialogue = text enclosed in quotation marks (straight or curly).
*/
function extractDialogueWords(text: string): number {
const dialogueRe = /[""\u201C](.*?)[""\u201D]/g;
let count = 0;
for (const re of DIALOGUE_PATTERNS) {
for (const match of text.matchAll(re)) {
const words = match[1].trim().split(/\s+/);
count += words.filter((w) => w.length > 0).length;
}
let m: RegExpExecArray | null;
while ((m = dialogueRe.exec(text)) !== null) {
const words = m[1].trim().split(/\s+/);
count += words.filter((w) => w.length > 0).length;
}
return count;
}
/** Strip dialogue spans across all supported quote styles, leaving narration. */
function stripDialogue(text: string): string {
let out = text;
for (const re of DIALOGUE_PATTERNS) {
out = out.replace(re, " ");
}
return out;
}
function totalWords(text: string): number {
const trimmed = text.trim();
if (trimmed.length === 0) return 0;
@@ -122,7 +80,7 @@ export function checkVoiceCompliance(prose: string): VoiceComplianceResult {
// --- Long narration sentences ---
// Remove dialogue from the text to isolate narration.
const narration = stripDialogue(prose);
const narration = prose.replace(/[""\u201C].*?[""\u201D]/g, "");
const sentences = splitSentences(narration);
const longNarrationSentences: string[] = [];
for (const sentence of sentences) {
-566
View File
@@ -1,185 +1,5 @@
/* PENNY - Prose Engine for Narrative, Notes, and Yarns */
/* Setup wizard modal */
.penny-wizard-modal .modal-content {
max-width: 580px;
}
.penny-wizard-progress {
display: flex;
align-items: center;
justify-content: center;
gap: 0;
margin-bottom: 20px;
padding: 16px 0 12px 0;
}
.penny-wizard-dot {
width: 28px;
height: 28px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 0.8em;
font-weight: 700;
border: 2px solid var(--background-modifier-border);
color: var(--text-muted);
background: var(--background-primary);
flex-shrink: 0;
}
.penny-wizard-dot-active {
border-color: var(--interactive-accent);
color: #fff;
background: var(--interactive-accent);
}
.penny-wizard-dot-done {
border-color: var(--color-green, #2ecc71);
color: #fff;
background: var(--color-green, #2ecc71);
}
.penny-wizard-line {
width: 40px;
height: 2px;
background: var(--background-modifier-border);
flex-shrink: 0;
}
.penny-wizard-line-done {
background: var(--color-green, #2ecc71);
}
.penny-wizard-title {
text-align: center;
margin: 0 0 16px 0;
font-size: 1.4em;
}
.penny-wizard-body {
min-height: 200px;
margin-bottom: 20px;
}
.penny-wizard-body p {
line-height: 1.55;
}
.penny-wizard-body .setting-item {
padding: 8px 0;
}
.penny-wizard-nav {
display: flex;
justify-content: space-between;
align-items: center;
padding-top: 12px;
border-top: 1px solid var(--background-modifier-border);
gap: 8px;
}
.penny-wizard-checklist {
margin: 16px 0;
display: flex;
flex-direction: column;
gap: 12px;
}
.penny-wizard-check-item {
display: flex;
align-items: flex-start;
gap: 12px;
}
.penny-wizard-check-num {
width: 26px;
height: 26px;
border-radius: 50%;
background: var(--interactive-accent);
color: #fff;
display: flex;
align-items: center;
justify-content: center;
font-size: 0.8em;
font-weight: 700;
flex-shrink: 0;
margin-top: 2px;
}
.penny-wizard-provider-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px;
margin: 12px 0 16px 0;
}
.penny-wizard-provider-card {
border: 2px solid var(--background-modifier-border);
border-radius: 8px;
padding: 12px;
cursor: pointer;
transition: border-color 0.15s;
}
.penny-wizard-provider-card:hover {
border-color: var(--text-accent);
}
.penny-wizard-provider-selected {
border-color: var(--interactive-accent);
background: var(--background-secondary);
}
.penny-wizard-provider-header {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 4px;
}
.penny-wizard-provider-name {
font-weight: 600;
font-size: 0.95em;
}
.penny-wizard-provider-badge {
font-size: 0.68em;
font-weight: 600;
padding: 1px 6px;
border-radius: 8px;
background: var(--interactive-accent);
color: #fff;
text-transform: uppercase;
letter-spacing: 0.03em;
}
.penny-wizard-detected {
padding: 10px 14px;
background: var(--background-secondary);
border-radius: 6px;
margin-bottom: 12px;
font-size: 0.9em;
}
.penny-wizard-detected ul {
margin: 4px 0 0 0;
padding-left: 20px;
}
.penny-wizard-detected li {
font-family: var(--font-monospace);
font-size: 0.9em;
}
.penny-wizard-summary p {
font-size: 0.95em;
padding: 8px 14px;
background: var(--background-secondary);
border-radius: 6px;
}
/* Settings header -- hero banner */
.penny-settings-header {
margin-bottom: 16px;
@@ -384,389 +204,3 @@
font-size: 0.9em;
color: var(--text-muted);
}
/* Progress modal.
Size rule lives on the OUTER wrapper (`.penny-progress-modal-wrapper`,
applied to Obsidian's `this.modalEl`) because `min-width` on the inner
contentEl doesn't reliably force the whole modal to grow. `max-width`
keeps it sane on narrow vault layouts. */
.penny-progress-modal-wrapper {
width: 760px;
max-width: 92vw;
}
.penny-progress-modal {
width: 100%;
}
.penny-progress-header-row {
display: flex;
justify-content: space-between;
align-items: baseline;
margin-bottom: 12px;
padding-bottom: 8px;
border-bottom: 1px solid var(--background-modifier-border);
}
.penny-progress-header {
font-weight: 600;
font-size: 1.1em;
flex: 1 1 auto;
min-width: 0;
overflow-wrap: anywhere;
}
.penny-progress-timer {
font-family: var(--font-monospace);
font-size: 0.9em;
color: var(--text-accent);
font-weight: 500;
flex: 0 0 auto;
margin-left: 12px;
white-space: nowrap;
}
.penny-inline-spinner {
display: inline-block;
width: 12px;
height: 12px;
border: 2px solid var(--text-faint);
border-top-color: var(--text-accent);
border-radius: 50%;
animation: penny-spin 0.8s linear infinite;
vertical-align: middle;
margin-right: 4px;
}
.penny-waiting-text {
color: var(--text-faint);
font-style: italic;
}
/* Outer log container. Intentionally does NOT scroll -- we want the spinner
line AND the stream preview's top to stay visible. The stream preview
itself has `overflow-y: auto` for token-level scrolling. */
.penny-progress-log {
font-family: var(--font-monospace);
font-size: 0.85em;
margin-bottom: 12px;
padding: 8px;
background: var(--background-secondary);
border-radius: 6px;
}
.penny-progress-line {
padding: 4px 0;
border-bottom: 1px solid var(--background-modifier-border);
}
.penny-progress-line:last-child {
border-bottom: none;
}
.penny-progress-active {
color: var(--text-accent);
}
.penny-progress-done {
color: var(--text-muted);
}
.penny-progress-error {
color: var(--text-error, #e74c3c);
}
/* Streaming preview -- live LLM output.
Uses `height` (not `max-height`) so the box reserves its full size from the
moment it appears. This prevents the modal from jumping around as tokens
arrive, and gives the user a stable reading area. Auto-scroll (JS side)
keeps the newest tokens visible at the bottom. */
.penny-stream-preview {
height: 320px;
overflow-y: auto;
padding: 8px 12px;
margin: 4px 0 8px 0;
background: var(--background-primary);
border: 1px solid var(--background-modifier-border);
border-radius: 4px;
font-size: 0.85em;
line-height: 1.5;
color: var(--text-normal);
white-space: pre-wrap;
word-break: break-word;
}
/* Error modal */
.penny-error-modal {
min-width: 450px;
}
.penny-error-box {
background: var(--background-secondary);
border: 1px solid var(--text-error, #e74c3c);
border-radius: 6px;
padding: 12px 16px;
margin: 12px 0;
}
.penny-error-message {
color: var(--text-normal);
font-weight: 500;
margin: 0;
}
.penny-error-details {
margin: 12px 0;
}
.penny-error-details summary {
cursor: pointer;
color: var(--text-muted);
font-size: 0.9em;
}
.penny-error-details pre {
background: var(--background-secondary);
padding: 8px 12px;
border-radius: 4px;
font-size: 0.8em;
max-height: 200px;
overflow-y: auto;
white-space: pre-wrap;
word-break: break-word;
}
.penny-error-log-path {
font-size: 0.85em;
color: var(--text-muted);
font-family: var(--font-monospace);
}
.penny-progress-buttons {
display: flex;
justify-content: flex-end;
gap: 8px;
}
/* "While you wait..." quote panel
Visible during active processing, hidden in terminal states (controlled by
inline style set in progress-modal.ts render()). Intentionally quiet --
should feel like atmosphere, not UI demanding attention. */
.penny-quote-panel {
margin: 12px 0;
padding: 14px 18px;
background: var(--background-secondary);
border-left: 3px solid var(--interactive-accent);
border-radius: 4px;
font-style: italic;
color: var(--text-muted);
transition: opacity 400ms ease;
}
.penny-quote-panel-label {
font-size: 0.72em;
font-style: normal;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--text-faint);
margin-bottom: 8px;
}
.penny-quote-text {
font-size: 0.95em;
line-height: 1.5;
margin-bottom: 4px;
}
.penny-quote-author {
font-size: 0.8em;
font-style: normal;
text-align: right;
color: var(--text-faint);
}
/* File conflict modal */
.penny-conflict-box {
background: var(--background-secondary);
border: 1px solid var(--background-modifier-border);
border-radius: 6px;
padding: 12px 16px;
margin: 12px 0;
}
.penny-conflict-path {
font-family: var(--font-monospace);
font-size: 0.9em;
font-weight: 500;
color: var(--text-normal);
word-break: break-all;
margin: 0 0 8px 0;
}
/* Model routing grid -- compact tier rows */
.penny-route-grid {
border: 1px solid var(--background-modifier-border);
border-radius: 8px;
overflow: hidden;
margin: 8px 0 16px 0;
}
.penny-route-grid .setting-item {
padding: 8px 14px;
border-bottom: 1px solid var(--background-modifier-border);
margin: 0;
}
.penny-route-grid .setting-item:last-child {
border-bottom: none;
}
.penny-route-grid .setting-item-info {
flex: 0 0 auto;
min-width: 140px;
}
.penny-route-grid .setting-item-name {
font-size: 0.92em;
font-weight: 600;
}
.penny-route-grid .setting-item-description {
display: none;
}
.penny-route-grid .setting-item-control {
gap: 8px;
}
.penny-route-grid .setting-item-control select {
font-size: 0.88em;
}
.penny-route-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 6px 14px;
background: var(--background-secondary);
border-bottom: 1px solid var(--background-modifier-border);
font-size: 0.78em;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--text-muted);
}
.penny-route-header span:first-child {
flex: 0 0 140px;
}
.penny-route-divider {
border-bottom: 2px solid var(--background-modifier-border);
}
.penny-route-muted {
color: var(--text-faint);
font-size: 0.82em;
font-style: italic;
}
/* Companion plugins section */
.penny-companion-grid {
display: flex;
flex-direction: column;
gap: 10px;
margin: 12px 0;
}
.penny-companion-card {
border: 1px solid var(--background-modifier-border);
border-radius: 8px;
padding: 14px 18px;
background: var(--background-primary);
transition: border-color 0.15s;
}
.penny-companion-card:hover {
border-color: var(--text-accent);
}
.penny-companion-dimmed {
opacity: 0.65;
}
.penny-companion-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 6px;
}
.penny-companion-title-wrap {
display: flex;
align-items: center;
gap: 10px;
}
.penny-companion-name {
font-weight: 600;
font-size: 1.02em;
}
.penny-companion-badge {
font-size: 0.72em;
font-weight: 600;
padding: 2px 8px;
border-radius: 10px;
text-transform: uppercase;
letter-spacing: 0.04em;
}
.penny-badge-installed {
background: var(--color-green, #2ecc71);
color: #fff;
}
.penny-badge-missing {
background: var(--background-modifier-border);
color: var(--text-muted);
}
.penny-companion-desc {
font-size: 0.88em;
color: var(--text-muted);
margin: 0 0 10px 0;
line-height: 1.45;
}
.penny-companion-actions {
display: flex;
align-items: center;
gap: 10px;
}
.penny-companion-actions .mod-cta {
font-size: 0.85em;
padding: 4px 14px;
}
.penny-companion-link {
font-size: 0.85em;
color: var(--text-accent);
text-decoration: none;
}
.penny-companion-link:hover {
text-decoration: underline;
}
.penny-companion-success {
margin-top: 8px;
padding: 6px 12px;
background: var(--color-green, #2ecc71);
color: #fff;
border-radius: 6px;
font-size: 0.85em;
font-weight: 500;
}
-59
View File
@@ -1,59 +0,0 @@
/**
* PENNY - Command ID Prefix Test
*
* Verifies that command IDs registered in commands.ts do NOT include
* the "penny:" prefix. Obsidian automatically prepends the plugin manifest
* ID to command IDs, so including "penny:" in the id field would produce
* double-prefixed IDs like "penny:penny:process-chapter".
*
* This is a structural test that reads the source file directly rather
* than importing the module (which depends on Obsidian APIs).
*/
import { describe, it, expect } from "vitest";
import { readFileSync } from "fs";
import { resolve } from "path";
const COMMANDS_SOURCE = readFileSync(
resolve(__dirname, "../src/commands.ts"),
"utf-8",
);
/**
* Extract all command ID strings from addCommand({ id: "..." }) calls.
* Matches both single-quoted and double-quoted id values.
*/
function extractCommandIds(source: string): string[] {
const pattern = /id:\s*["']([^"']+)["']/g;
const ids: string[] = [];
let match;
while ((match = pattern.exec(source)) !== null) {
ids.push(match[1]);
}
return ids;
}
describe("command registration IDs", () => {
const ids = extractCommandIds(COMMANDS_SOURCE);
it("finds at least one command ID in the source", () => {
expect(ids.length).toBeGreaterThan(0);
});
it("no command ID starts with 'penny:' (Obsidian adds the prefix automatically)", () => {
for (const id of ids) {
expect(id).not.toMatch(/^penny:/);
}
});
it("all command IDs are kebab-case (lowercase alphanumeric with hyphens)", () => {
for (const id of ids) {
expect(id).toMatch(/^[a-z][a-z0-9-]*$/);
}
});
it("no duplicate command IDs exist", () => {
const unique = new Set(ids);
expect(unique.size).toBe(ids.length);
});
});
-24
View File
@@ -383,30 +383,6 @@ describe("detectCharacters", () => {
expect(chars).toHaveLength(0);
});
it("captures two-word names (#33)", () => {
const content = '"Run!" Mary Jane shouted.';
const chars = detectCharacters(content, "");
expect(chars).toContain("mary jane");
});
it("captures apostrophe names (#33)", () => {
const content = '"Get out," O\'Brien snapped.';
const chars = detectCharacters(content, "");
expect(chars).toContain("o'brien");
});
it("captures hyphenated names (#33)", () => {
const content = '"Hi," Mary-Anne whispered.';
const chars = detectCharacters(content, "");
expect(chars).toContain("mary-anne");
});
it("captures all-caps shouted attribution (#33)", () => {
const content = '"WAIT!" MARY shouted.';
const chars = detectCharacters(content, "");
expect(chars).toContain("mary");
});
it("handles comma-separated focus field", () => {
const chars = detectCharacters("", "protagonist, tim, rabbit");
expect(chars).toContain("protagonist");
+8 -272
View File
@@ -349,8 +349,14 @@ Some prose without any annotations.
const result = await runPipeline(input);
// When all annotations fail, pipeline returns null (no version created)
expect(result).toBeNull();
expect(result).not.toBeNull();
const r = result as PipelineResult;
expect(r.hadErrors).toBe(true);
expect(r.newContent).toContain("AGENT-ERROR");
expect(r.newContent).toContain("not available");
// No annotations should be counted as successfully processed
expect(r.annotationsProcessed).toBe(0);
});
it("updates frontmatter with agent fields", async () => {
@@ -451,274 +457,4 @@ She walked in.
// readVersion("") returns 0, so nextVersion should be 1
expect(result!.newVersion).toBe(1);
});
it("uses non-default maxTokens (8000) and passes it to the provider", async () => {
const mock = createMockProvider(["Revised with lower token limit."]);
const settingsWithLowTokens: PennySettings = {
...TEST_SETTINGS,
maxTokens: 8000,
};
const simpleChapter = `---
type: chapter
book: 1
chapter: 1
---
She walked in.
%% REWRITE: Add detail %%
`;
const input: PipelineInput = {
content: simpleChapter,
versionContent: "0",
stateContent: "",
contextFiles: makeContextFiles(simpleChapter),
settings: settingsWithLowTokens,
chapterId: "ch-01",
bookId: "book-1",
getProvider: () => mock.provider,
};
const result = await runPipeline(input);
expect(result).not.toBeNull();
const calls = mock.getCalls();
expect(calls).toHaveLength(1);
expect(calls[0].maxTokens).toBe(8000);
});
it("returns null when cancelled before any annotation is processed", async () => {
const mock = createMockProvider([
"Should never be used.",
"Also should never be used.",
]);
const input: PipelineInput = {
content: TEST_CHAPTER,
versionContent: "1",
stateContent: "",
contextFiles: makeContextFiles(TEST_CHAPTER),
settings: TEST_SETTINGS,
chapterId: "ch-05",
bookId: "book-1",
getProvider: () => mock.provider,
isCancelled: () => true, // Cancelled from the start
};
const result = await runPipeline(input);
// Should return null -- cancelled before any work
expect(result).toBeNull();
// Provider should never be called
expect(mock.getCalls()).toHaveLength(0);
});
it("returns null when cancelled after first annotation (no partial version)", async () => {
let callCount = 0;
const mock = createMockProvider([
"Revised first passage.",
"Revised second passage.",
]);
// Cancel after the first annotation completes
const input: PipelineInput = {
content: TEST_CHAPTER,
versionContent: "1",
stateContent: "",
contextFiles: makeContextFiles(TEST_CHAPTER),
settings: TEST_SETTINGS,
chapterId: "ch-05",
bookId: "book-1",
getProvider: () => {
callCount++;
return mock.provider;
},
isCancelled: () => callCount >= 1, // Cancel after first annotation starts
};
const result = await runPipeline(input);
// Should return null -- cancelled run must not produce a version
expect(result).toBeNull();
});
it("emits a cancelled event with Cancelled message when cancelled mid-run", async () => {
const progressEvents: Array<{ type: string; message?: string }> = [];
const mock = createMockProvider([
"Revised first passage.",
"Revised second passage.",
]);
const input: PipelineInput = {
content: TEST_CHAPTER,
versionContent: "1",
stateContent: "",
contextFiles: makeContextFiles(TEST_CHAPTER),
settings: TEST_SETTINGS,
chapterId: "ch-05",
bookId: "book-1",
getProvider: () => mock.provider,
onProgress: (event) => {
progressEvents.push({ type: event.type, message: event.message });
},
isCancelled: () => {
// Cancel after at least one provider call has been made
return mock.getCalls().length >= 1;
},
};
const result = await runPipeline(input);
// Should return null
expect(result).toBeNull();
// Should have emitted a "cancelled" event (distinct from "complete")
const cancelledEvents = progressEvents.filter((e) => e.type === "cancelled");
expect(cancelledEvents.length).toBeGreaterThanOrEqual(1);
expect(cancelledEvents.some((e) => e.message?.includes("Cancelled"))).toBe(true);
// And should NOT have emitted a "complete" event -- a cancelled run is not complete
const completeEvents = progressEvents.filter((e) => e.type === "complete");
expect(completeEvents.length).toBe(0);
});
it("produces valid file outputs for upsert on first run (create path)", async () => {
const mock = createMockProvider(["Revised passage for version file."]);
const simpleChapter = `---
type: chapter
book: 1
chapter: 1
---
She walked in.
%% REWRITE: Add detail %%
`;
// First run: empty version and state content (files don't exist yet)
const input: PipelineInput = {
content: simpleChapter,
versionContent: "",
stateContent: "",
contextFiles: makeContextFiles(simpleChapter),
settings: TEST_SETTINGS,
chapterId: "ch-01",
bookId: "book-1",
getProvider: () => mock.provider,
};
const result = await runPipeline(input);
expect(result).not.toBeNull();
const r = result as PipelineResult;
// newVersion is a number that would be written to the .version file
expect(r.newVersion).toBe(1);
// stateJson is valid JSON that would be written to .state.json
const state = JSON.parse(r.stateJson);
expect(state.version).toBe(1);
expect(state.processedAnnotations).toHaveLength(1);
expect(state.lastProcessed).toBeDefined();
// newContent is the full chapter content that replaces the original
expect(r.newContent).toContain("REVISED(v1)");
// reviewContent is the markdown review note
expect(r.reviewContent.length).toBeGreaterThan(0);
expect(r.reviewContent).toContain("# ch-01 Review -- v1");
});
it("produces valid file outputs for upsert on subsequent run (modify path)", async () => {
const mock1 = createMockProvider(["First revision."]);
const simpleChapter = `---
type: chapter
book: 1
chapter: 1
---
She walked in.
%% REWRITE: Add detail %%
`;
// First run to get state
const input1 = makePipelineInput(simpleChapter, "", "", mock1);
const result1 = await runPipeline(input1);
expect(result1).not.toBeNull();
// Now simulate a second run where the files already exist:
// The version file contains "1", the state file has the first run's state.
// Add a new annotation to the already-processed content.
const secondChapter = result1!.newContent + "\n\nNew paragraph.\n%% EXPAND: expand this %%\n";
const mock2 = createMockProvider(["Expanded new paragraph with detail."]);
const input2: PipelineInput = {
content: secondChapter,
versionContent: String(result1!.newVersion),
stateContent: result1!.stateJson,
contextFiles: makeContextFiles(secondChapter),
settings: TEST_SETTINGS,
chapterId: "ch-01",
bookId: "book-1",
getProvider: () => mock2.provider,
};
const result2 = await runPipeline(input2);
expect(result2).not.toBeNull();
const r2 = result2 as PipelineResult;
// Version increments -- this is the value that upsertFile would overwrite
expect(r2.newVersion).toBe(2);
// State accumulates -- upsertFile overwrites the state file
const state2 = JSON.parse(r2.stateJson);
expect(state2.version).toBe(2);
// Should have the original processed annotation plus the new one
expect(state2.processedAnnotations.length).toBeGreaterThanOrEqual(2);
// The review is for version 2
expect(r2.reviewContent).toContain("v2");
});
it("works with empty customVoiceRules", async () => {
const mock = createMockProvider(["Revised passage."]);
const settingsEmptyVoice: PennySettings = {
...TEST_SETTINGS,
customVoiceRules: "",
};
const simpleChapter = `---
type: chapter
book: 1
chapter: 1
---
She walked in.
%% REWRITE: Add detail %%
`;
const input: PipelineInput = {
content: simpleChapter,
versionContent: "0",
stateContent: "",
contextFiles: makeContextFiles(simpleChapter),
settings: settingsEmptyVoice,
chapterId: "ch-01",
bookId: "book-1",
getProvider: () => mock.provider,
};
const result = await runPipeline(input);
expect(result).not.toBeNull();
const calls = mock.getCalls();
expect(calls).toHaveLength(1);
// System prompt should still be valid (non-empty)
expect(calls[0].systemPrompt.length).toBeGreaterThan(0);
// The prompt should still function without voice rules
expect(calls[0].userPrompt).toContain("REWRITE");
});
});
-179
View File
@@ -327,183 +327,4 @@ describe("parseAnnotations", () => {
expect(result).toHaveLength(0);
});
});
describe("block scope with {{ }}", () => {
it("scopes to content inside {{ }} markers", () => {
const content = [
"{{",
"First paragraph of the block.",
"",
"Second paragraph of the block.",
"}}",
"%% REWRITE: rewrite the whole block %%",
].join("\n");
const result = parseAnnotations(content);
expect(result).toHaveLength(1);
expect(result[0].scope).toBe("block");
expect(result[0].originalText).toContain("First paragraph");
expect(result[0].originalText).toContain("Second paragraph");
});
it("does not include {{ }} markers in originalText", () => {
const content = [
"{{",
"The target text.",
"}}",
"%% TONE: make it darker %%",
].join("\n");
const result = parseAnnotations(content);
expect(result).toHaveLength(1);
expect(result[0].originalText).toBe("The target text.");
expect(result[0].originalText).not.toContain("{{");
expect(result[0].originalText).not.toContain("}}");
});
it("handles blank line between }} and annotation", () => {
const content = [
"{{",
"Block content here.",
"}}",
"",
"%% EXPAND: add more detail %%",
].join("\n");
const result = parseAnnotations(content);
expect(result).toHaveLength(1);
expect(result[0].scope).toBe("block");
expect(result[0].originalText).toBe("Block content here.");
});
it("captures multi-paragraph blocks", () => {
const content = [
"{{",
"I grind the beans. Burr grinder, not blade.",
"",
"I fill the kettle. 205 degrees.",
"",
"The grinder is the loudest thing.",
"",
"I pour it black.",
"}}",
"%% REWRITE: change to espresso %%",
].join("\n");
const result = parseAnnotations(content);
expect(result).toHaveLength(1);
expect(result[0].scope).toBe("block");
expect(result[0].originalText).toContain("I grind the beans");
expect(result[0].originalText).toContain("I pour it black.");
});
it("falls back to paragraph scope when no matching {{ found", () => {
const content = [
"Some text above.",
"}}",
"%% REWRITE: rewrite this %%",
].join("\n");
const result = parseAnnotations(content);
expect(result).toHaveLength(1);
// }} without matching {{ -- parser falls through, finds paragraph above
expect(result[0].scope).toBe("paragraph");
});
it("handles multiple block-scoped annotations", () => {
const content = [
"{{",
"First block.",
"}}",
"%% REWRITE: fix first block %%",
"",
"{{",
"Second block.",
"}}",
"%% TONE: change tone of second block %%",
].join("\n");
const result = parseAnnotations(content);
expect(result).toHaveLength(2);
expect(result[0].scope).toBe("block");
expect(result[0].originalText).toBe("First block.");
expect(result[1].scope).toBe("block");
expect(result[1].originalText).toBe("Second block.");
});
it("handles empty block ({{ }} with no content between them)", () => {
const content = [
"{{",
"}}",
"%% REWRITE: fill this in %%",
].join("\n");
const result = parseAnnotations(content);
expect(result).toHaveLength(1);
expect(result[0].scope).toBe("block");
// lineStart > lineEnd when block is empty (open+1 > close-1)
// originalText should be empty since there is nothing between {{ and }}
expect(result[0].originalText).toBe("");
});
it("finds nearest matching {{ for nested-looking markers", () => {
// When there are two {{ before a }}, the parser walks backwards
// and finds the first {{ it encounters (nearest to the }}).
const content = [
"{{",
"Outer block start.",
"{{",
"Inner block content.",
"}}",
"%% REWRITE: rewrite inner %%",
].join("\n");
const result = parseAnnotations(content);
expect(result).toHaveLength(1);
expect(result[0].scope).toBe("block");
// Should find the nearest {{ (line 2) as the match for }} (line 4)
expect(result[0].originalText).toContain("Inner block content.");
});
it("does NOT treat {{ as block open when mixed with other text on same line", () => {
// isBlockOpen requires the line to be exactly "{{" (with optional whitespace)
const content = [
"She said {{ something }}",
"%% REWRITE: fix this line %%",
].join("\n");
const result = parseAnnotations(content);
expect(result).toHaveLength(1);
// {{ on a line with other text is not treated as a block opener,
// so scope resolution falls through to paragraph
expect(result[0].scope).toBe("paragraph");
});
it("second annotation after a block does not get block scope", () => {
// After a {{ }} block and its annotation, a subsequent annotation
// separated by other content should NOT resolve to block scope.
const content = [
"{{",
"Block content here.",
"}}",
"%% REWRITE: rewrite the block %%",
"",
"Some unrelated paragraph.",
"%% TONE: make it darker %%",
].join("\n");
const result = parseAnnotations(content);
expect(result).toHaveLength(2);
// Find each annotation by tag since sort order depends on lineStart
const rewrite = result.find((a) => a.tag === "REWRITE");
const tone = result.find((a) => a.tag === "TONE");
expect(rewrite).toBeDefined();
expect(tone).toBeDefined();
// REWRITE sits right after }}, so it gets block scope
expect(rewrite!.scope).toBe("block");
expect(rewrite!.originalText).toBe("Block content here.");
// TONE follows a normal paragraph, so it gets paragraph scope
expect(tone!.scope).toBe("paragraph");
});
});
});
-305
View File
@@ -1,305 +0,0 @@
/**
* PENNY - Pipeline Cancellation Tests
*
* Verifies that the pipeline's cancellation spine works correctly:
* - Aborting an AbortController mid-annotation propagates to the provider
* - An AbortError thrown by the provider is translated to a "cancelled" event
* - The pipeline resolves with null on cancellation (no partial version)
* - The AbortSignal passed to the pipeline reaches the provider's complete() call
* - Pre-aborted signals short-circuit before any provider call
*
* These tests complement integration.test.ts, which exercises the legacy
* isCancelled callback path. This file exercises the AbortSignal path added
* in the cancellation-spine refactor.
*/
import { describe, it, expect } from "vitest";
import { runPipeline } from "../src/pipeline";
import type { PipelineInput, ProgressEvent } from "../src/pipeline";
import type { PennySettings } from "../src/types";
import { DEFAULT_SETTINGS, DEFAULT_SYSTEM_PROMPT } from "../src/types";
import type { ContextFiles } from "../src/context";
import type { CompletionRequest, CompletionResponse } from "../src/providers/service";
const TEST_CHAPTER = `---
type: chapter
book: 1
chapter: 5
title: The Discovery
pov: protagonist
status: draft
wordcount: 100
focus: protagonist
---
# Book 1, Chapter 5
<!-- Prose begins below -->
The lab was quiet at 3am.
%% REWRITE: Add sensory detail %%
She stared at the screen.
%% EXPAND: Add dialogue with Rabbit %%
The data made no sense.
%% REWRITE: Clarify the anomaly %%
`;
const TEST_SETTINGS: PennySettings = {
...DEFAULT_SETTINGS,
systemPromptTemplate: DEFAULT_SYSTEM_PROMPT,
proseMarker: "<!-- Prose begins below -->",
};
function makeContextFiles(chapter: string): ContextFiles {
return {
chapter,
voiceTests: "",
styleGuide: "",
outline: "",
characters: [],
wiki: [],
seriesBible: "",
themes: "",
};
}
/**
* Create a provider that records the signal it receives on each call, and
* respects the signal by throwing AbortError if already aborted or when the
* signal fires mid-call.
*/
function createSignalAwareProvider() {
const receivedSignals: Array<AbortSignal | undefined> = [];
const calls: CompletionRequest[] = [];
return {
receivedSignals,
calls,
provider: {
name: "signal-aware",
requiresApiKey: false,
tokenMultiplier: 1.0,
async complete(request: CompletionRequest): Promise<CompletionResponse> {
calls.push(request);
receivedSignals.push(request.signal);
// If the signal is already aborted, reject immediately (like a real
// provider using streamRequest would).
if (request.signal?.aborted) {
const err = new Error("Request aborted");
err.name = "AbortError";
throw err;
}
// Otherwise simulate an in-flight call that watches the signal.
return await new Promise<CompletionResponse>((resolve, reject) => {
const onAbort = () => {
const err = new Error("Request aborted");
err.name = "AbortError";
reject(err);
};
request.signal?.addEventListener("abort", onAbort, { once: true });
// Resolve on the next tick to give the test a chance to abort.
setTimeout(() => {
request.signal?.removeEventListener("abort", onAbort);
if (request.signal?.aborted) {
const err = new Error("Request aborted");
err.name = "AbortError";
reject(err);
return;
}
resolve({
text: "Revised passage.",
model: request.model,
provider: "signal-aware",
});
}, 5);
});
},
estimateTokens: (text: string) => text.split(/\s+/).length,
async getModels() {
return [];
},
async testConnection() {
return null;
},
},
};
}
function makeInputWithSignal(
signal: AbortSignal,
providerFactory: ReturnType<typeof createSignalAwareProvider>,
onProgress?: (ev: ProgressEvent) => void,
): PipelineInput {
return {
content: TEST_CHAPTER,
versionContent: "1",
stateContent: "",
contextFiles: makeContextFiles(TEST_CHAPTER),
settings: TEST_SETTINGS,
chapterId: "ch-05",
bookId: "book-1",
getProvider: () => providerFactory.provider,
signal,
onProgress,
};
}
describe("runPipeline cancellation (AbortSignal)", () => {
it("passes the AbortSignal through to the provider's complete() call", async () => {
const controller = new AbortController();
const pf = createSignalAwareProvider();
const result = await runPipeline(makeInputWithSignal(controller.signal, pf));
// Should succeed normally (never aborted)
expect(result).not.toBeNull();
expect(pf.receivedSignals.length).toBeGreaterThan(0);
// The provider received the same signal instance that the pipeline was given
for (const sig of pf.receivedSignals) {
expect(sig).toBe(controller.signal);
}
});
it("resolves with null and emits 'cancelled' when the signal aborts mid-annotation", async () => {
const controller = new AbortController();
const pf = createSignalAwareProvider();
const events: ProgressEvent[] = [];
// Abort on the first annotation-start event, which fires before the first
// provider call resolves. The in-flight call should then reject with AbortError.
const input = makeInputWithSignal(controller.signal, pf, (ev) => {
events.push(ev);
if (ev.type === "annotation-start" && ev.current === 1) {
controller.abort();
}
});
const result = await runPipeline(input);
// Cancelled mid-run must NOT produce a version
expect(result).toBeNull();
// A "cancelled" event must have been emitted
const cancelled = events.filter((e) => e.type === "cancelled");
expect(cancelled.length).toBeGreaterThanOrEqual(1);
expect(cancelled[0].message).toMatch(/Cancelled/i);
// And no "complete" event -- cancelled is not completion
const complete = events.filter((e) => e.type === "complete");
expect(complete.length).toBe(0);
});
it("short-circuits before any provider call when signal is already aborted", async () => {
const controller = new AbortController();
controller.abort(); // pre-aborted
const pf = createSignalAwareProvider();
const events: ProgressEvent[] = [];
const result = await runPipeline(
makeInputWithSignal(controller.signal, pf, (ev) => events.push(ev)),
);
expect(result).toBeNull();
// Provider should never have been invoked -- the loop-top check catches it
expect(pf.calls.length).toBe(0);
// A cancelled event is emitted from the loop-top cancellation check
const cancelled = events.filter((e) => e.type === "cancelled");
expect(cancelled.length).toBeGreaterThanOrEqual(1);
});
it("does not create a version or write state when cancelled", async () => {
const controller = new AbortController();
const pf = createSignalAwareProvider();
const input = makeInputWithSignal(controller.signal, pf, (ev) => {
if (ev.type === "annotation-start") controller.abort();
});
const result = await runPipeline(input);
// The null result is the contract: commands.ts uses this to decide whether
// to write the .version / .state.json files. If result is null, nothing is written.
expect(result).toBeNull();
});
it("treats AbortError from the provider as cancellation (not as an API error)", async () => {
// Provider that always throws AbortError -- simulates streamRequest reacting
// to req.destroy() when the signal aborts mid-stream.
const abortingProvider = {
name: "aborting",
requiresApiKey: false,
async complete(_req: CompletionRequest): Promise<CompletionResponse> {
const err = new Error("Request aborted");
err.name = "AbortError";
throw err;
},
estimateTokens: (t: string) => t.split(/\s+/).length,
async getModels() {
return [];
},
async testConnection() {
return null;
},
};
const controller = new AbortController();
const events: ProgressEvent[] = [];
const input: PipelineInput = {
content: TEST_CHAPTER,
versionContent: "1",
stateContent: "",
contextFiles: makeContextFiles(TEST_CHAPTER),
settings: TEST_SETTINGS,
chapterId: "ch-05",
bookId: "book-1",
getProvider: () => abortingProvider,
signal: controller.signal,
onProgress: (ev) => events.push(ev),
};
const result = await runPipeline(input);
// AbortError from the provider must translate to a null result (no version)
expect(result).toBeNull();
// It must NOT be surfaced as an AGENT-ERROR annotation-error event
const annotationErrors = events.filter((e) => e.type === "annotation-error");
expect(annotationErrors.length).toBe(0);
// It must emit a cancelled event instead
const cancelled = events.filter((e) => e.type === "cancelled");
expect(cancelled.length).toBeGreaterThanOrEqual(1);
});
it("accepts either isCancelled callback or AbortSignal as a cancellation source", async () => {
// Exercises the unified isCancelled() helper: signal aborted but no callback.
const controller = new AbortController();
controller.abort();
const pf = createSignalAwareProvider();
const result = await runPipeline({
content: TEST_CHAPTER,
versionContent: "1",
stateContent: "",
contextFiles: makeContextFiles(TEST_CHAPTER),
settings: TEST_SETTINGS,
chapterId: "ch-05",
bookId: "book-1",
getProvider: () => pf.provider,
signal: controller.signal,
// no isCancelled callback -- signal alone should cancel
});
expect(result).toBeNull();
expect(pf.calls.length).toBe(0);
});
});
-122
View File
@@ -1,122 +0,0 @@
/**
* PENNY - Pipeline Retry Event Tests
*
* Verifies that the pipeline forwards a provider-emitted onRetry callback
* to a ProgressEvent("retry", ...) for the modal, and threads
* settings.maxRetries through to the provider.
*/
import { describe, it, expect } from "vitest";
import { runPipeline } from "../src/pipeline";
import type { PipelineInput, ProgressEvent } from "../src/pipeline";
import type { PennySettings } from "../src/types";
import { DEFAULT_SETTINGS, DEFAULT_SYSTEM_PROMPT } from "../src/types";
import type { ContextFiles } from "../src/context";
import type { CompletionRequest, CompletionResponse } from "../src/providers/service";
const TEST_CHAPTER = `---
type: chapter
book: 1
chapter: 5
title: Retry Test
pov: protagonist
status: draft
wordcount: 50
focus: protagonist
---
# Book 1, Chapter 5
<!-- Prose begins below -->
The lab was quiet at 3am.
%% REWRITE: Add sensory detail %%
`;
const TEST_SETTINGS: PennySettings = {
...DEFAULT_SETTINGS,
systemPromptTemplate: DEFAULT_SYSTEM_PROMPT,
proseMarker: "<!-- Prose begins below -->",
};
function makeContextFiles(chapter: string): ContextFiles {
return {
chapter,
voiceTests: "",
styleGuide: "",
outline: "",
characters: [],
wiki: [],
seriesBible: "",
themes: "",
};
}
describe("runPipeline retry event forwarding", () => {
it("threads settings.maxRetries into the provider request", async () => {
let observedMaxRetries: number | undefined;
const provider = {
name: "stub",
requiresApiKey: false,
tokenMultiplier: 1.0,
async complete(req: CompletionRequest): Promise<CompletionResponse> {
observedMaxRetries = req.maxRetries;
return { text: "revised", usage: {}, model: req.model, provider: "stub" };
},
};
const input: PipelineInput = {
content: TEST_CHAPTER,
versionContent: "1",
stateContent: "",
contextFiles: makeContextFiles(TEST_CHAPTER),
settings: { ...TEST_SETTINGS, maxRetries: 5 },
chapterId: "ch-05",
bookId: "book-1",
getProvider: () => provider,
};
await runPipeline(input);
expect(observedMaxRetries).toBe(5);
});
it("emits a ProgressEvent('retry', ...) when the provider invokes onRetry", async () => {
// The provider simulates the inner withRetry: invokes onRetry then returns
// success on the same call (the mock represents the wrapped result).
const provider = {
name: "retrying",
requiresApiKey: false,
tokenMultiplier: 1.0,
async complete(req: CompletionRequest): Promise<CompletionResponse> {
req.onRetry?.({ attempt: 2, waitMs: 100, reason: "HTTP 429" });
return { text: "revised", usage: {}, model: req.model, provider: "retrying" };
},
};
const events: ProgressEvent[] = [];
const input: PipelineInput = {
content: TEST_CHAPTER,
versionContent: "1",
stateContent: "",
contextFiles: makeContextFiles(TEST_CHAPTER),
settings: TEST_SETTINGS,
chapterId: "ch-05",
bookId: "book-1",
getProvider: () => provider,
onProgress: (e) => events.push(e),
};
await runPipeline(input);
const retryEvents = events.filter((e) => e.type === "retry");
expect(retryEvents).toHaveLength(1);
expect(retryEvents[0]).toMatchObject({
type: "retry",
attempt: 2,
waitMs: 100,
reason: "HTTP 429",
current: 1,
total: 1,
});
});
});
-247
View File
@@ -1,247 +0,0 @@
/**
* PENNY - Progress Modal State Machine Tests
*
* Exercises the pure state machine that drives PennyProgressModal. The state
* machine owns all non-trivial logic (state transitions, button visibility,
* cancel-vs-close distinction, timer start/stop). The Obsidian Modal itself
* is a thin DOM-rendering shell around this state machine.
*
* Testing the state machine directly sidesteps the fact that `obsidian` is a
* .d.ts-only package with no runtime -- instantiating Modal in tests is not
* practical. These tests cover every bug from the previous modal:
* - Cancel and Close are distinct actions, never share a handler
* - Clicking Close from a done/cancelled/error state never re-cancels
* - Cancel moves directly to "cancelled" (no "Cancelling..." transient)
* - onAbort fires exactly once per run, even if Cancel is clicked twice
* - Quote panel is visible throughout processing, hidden when done
*/
import { describe, it, expect, vi } from "vitest";
import { ProgressModalStateMachine } from "../src/progress-modal-state";
import type { ProgressEvent } from "../src/pipeline";
function makeMachine(opts?: { onAbort?: () => void }) {
return new ProgressModalStateMachine({ onAbort: opts?.onAbort });
}
describe("ProgressModalStateMachine — initial state", () => {
it("starts in 'starting' with timer running, cancel+minimize visible, no close", () => {
const m = makeMachine();
expect(m.state).toBe("starting");
const v = m.view();
expect(v.timerRunning).toBe(true);
expect(v.cancelButtonVisible).toBe(true);
expect(v.minimizeButtonVisible).toBe(true);
expect(v.closeButtonVisible).toBe(false);
});
});
describe("ProgressModalStateMachine — event-driven transitions", () => {
it("start -> processing on annotation-start", () => {
const m = makeMachine();
m.handleEvent({ type: "annotation-start", current: 1, total: 3, message: "Processing 1/3" });
expect(m.state).toBe("processing");
expect(m.view().cancelButtonVisible).toBe(true);
});
it("processing -> assembling on assembling event", () => {
const m = makeMachine();
m.handleEvent({ type: "annotation-start", current: 1, total: 1 });
m.handleEvent({ type: "assembling", message: "Assembling..." });
expect(m.state).toBe("assembling");
});
it("assembling -> done on complete event, timer stops, Close replaces Cancel", () => {
const m = makeMachine();
m.handleEvent({ type: "assembling" });
m.handleEvent({ type: "complete", message: "Complete" });
expect(m.state).toBe("done");
const v = m.view();
expect(v.timerRunning).toBe(false);
expect(v.cancelButtonVisible).toBe(false);
expect(v.closeButtonVisible).toBe(true);
expect(v.minimizeButtonVisible).toBe(false);
});
it("any state -> cancelled on 'cancelled' pipeline event", () => {
const m = makeMachine();
m.handleEvent({ type: "annotation-start", current: 1, total: 3 });
m.handleEvent({ type: "cancelled", message: "Cancelled after 0 annotations" });
expect(m.state).toBe("cancelled");
const v = m.view();
expect(v.timerRunning).toBe(false);
expect(v.cancelButtonVisible).toBe(false);
expect(v.closeButtonVisible).toBe(true);
});
it("emits 'error' state when pipeline surfaces a fatal annotation-error with no recovery", () => {
// annotation-error alone doesn't mean fatal -- pipelines recover from per-annotation
// errors. But if the modal receives a top-level error event, it transitions.
// We model this via a dedicated "error" event for clarity.
const m = makeMachine();
m.handleEvent({ type: "annotation-start", current: 1, total: 1 });
m.setErrorState("Pipeline failed");
expect(m.state).toBe("error");
expect(m.view().closeButtonVisible).toBe(true);
expect(m.view().cancelButtonVisible).toBe(false);
});
});
describe("ProgressModalStateMachine — cancel action", () => {
it("requestCancel() from processing moves to 'cancelled' and fires onAbort once", () => {
const onAbort = vi.fn();
const m = makeMachine({ onAbort });
m.handleEvent({ type: "annotation-start", current: 1, total: 3 });
m.requestCancel();
expect(m.state).toBe("cancelled");
expect(onAbort).toHaveBeenCalledTimes(1);
});
it("requestCancel() called twice fires onAbort exactly once (idempotent)", () => {
const onAbort = vi.fn();
const m = makeMachine({ onAbort });
m.handleEvent({ type: "annotation-start", current: 1, total: 3 });
m.requestCancel();
m.requestCancel();
expect(onAbort).toHaveBeenCalledTimes(1);
expect(m.state).toBe("cancelled");
});
it("requestCancel() from 'done' is a no-op and does NOT call onAbort", () => {
// This is the classic bug from the old modal: clicking "Close" after complete
// triggered the Cancel handler and set "Cancelling...". Never again.
const onAbort = vi.fn();
const m = makeMachine({ onAbort });
m.handleEvent({ type: "complete", message: "Done" });
m.requestCancel();
expect(onAbort).not.toHaveBeenCalled();
expect(m.state).toBe("done");
});
it("requestCancel() from 'error' is a no-op", () => {
const onAbort = vi.fn();
const m = makeMachine({ onAbort });
m.setErrorState("Boom");
m.requestCancel();
expect(onAbort).not.toHaveBeenCalled();
expect(m.state).toBe("error");
});
it("requestCancel() sets header to 'Cancelled' directly (no 'Cancelling...' transient)", () => {
const m = makeMachine();
m.handleEvent({ type: "annotation-start", current: 1, total: 3 });
m.requestCancel();
expect(m.view().header).toMatch(/^Cancelled/);
});
it("requestCancel() works even when onAbort is not provided", () => {
const m = makeMachine(); // no onAbort
m.handleEvent({ type: "annotation-start", current: 1, total: 3 });
expect(() => m.requestCancel()).not.toThrow();
expect(m.state).toBe("cancelled");
});
});
describe("ProgressModalStateMachine — close action", () => {
it("canClose() returns true from terminal states", () => {
const m1 = makeMachine();
m1.handleEvent({ type: "complete" });
expect(m1.canClose()).toBe(true);
const m2 = makeMachine();
m2.requestCancel();
expect(m2.canClose()).toBe(true);
const m3 = makeMachine();
m3.setErrorState("x");
expect(m3.canClose()).toBe(true);
});
it("canClose() returns false from active states", () => {
const m = makeMachine();
expect(m.canClose()).toBe(false);
m.handleEvent({ type: "annotation-start", current: 1, total: 1 });
expect(m.canClose()).toBe(false);
m.handleEvent({ type: "assembling" });
expect(m.canClose()).toBe(false);
});
});
describe("ProgressModalStateMachine — header text", () => {
it("reflects the most recent event's message when provided", () => {
const m = makeMachine();
m.handleEvent({ type: "start", message: "Processing chapter 5" });
expect(m.view().header).toBe("Processing chapter 5");
});
it("falls back to a default header when no message is provided", () => {
const m = makeMachine();
m.handleEvent({ type: "annotation-start", current: 2, total: 5 });
// Processing state should produce a reasonable default header
expect(m.view().header.length).toBeGreaterThan(0);
});
it("complete state reads 'Complete' by default", () => {
const m = makeMachine();
m.handleEvent({ type: "complete" });
expect(m.view().header).toMatch(/complete/i);
});
});
describe("ProgressModalStateMachine — stream preview and quote panel visibility", () => {
it("quote panel is visible during active processing", () => {
const m = makeMachine();
m.handleEvent({ type: "annotation-start", current: 1, total: 1 });
expect(m.view().quotePanelVisible).toBe(true);
});
it("quote panel is hidden in terminal states", () => {
const m1 = makeMachine();
m1.handleEvent({ type: "complete" });
expect(m1.view().quotePanelVisible).toBe(false);
const m2 = makeMachine();
m2.requestCancel();
expect(m2.view().quotePanelVisible).toBe(false);
});
});
describe("ProgressModalStateMachine — timer", () => {
it("timer keeps running during assembling state", () => {
const m = makeMachine();
m.handleEvent({ type: "assembling" });
expect(m.view().timerRunning).toBe(true);
});
it("timer stops in every terminal state", () => {
const finalStates: Array<() => ProgressModalStateMachine> = [
() => { const m = makeMachine(); m.handleEvent({ type: "complete" }); return m; },
() => { const m = makeMachine(); m.requestCancel(); return m; },
() => { const m = makeMachine(); m.setErrorState("x"); return m; },
];
for (const setup of finalStates) {
expect(setup().view().timerRunning).toBe(false);
}
});
});
describe("ProgressModalStateMachine — ProgressEvent passthrough", () => {
it("does not crash on token/annotation-done events (they don't change state)", () => {
const m = makeMachine();
m.handleEvent({ type: "annotation-start", current: 1, total: 2 });
expect(() => {
m.handleEvent({ type: "token", text: "hello" });
m.handleEvent({ type: "annotation-done", current: 1, total: 2 });
}).not.toThrow();
expect(m.state).toBe("processing");
});
it("annotation-error alone does NOT transition to error state", () => {
// annotation-error is recoverable -- the pipeline continues to the next annotation.
// Only a top-level fatal error moves the modal to "error" state.
const m = makeMachine();
m.handleEvent({ type: "annotation-start", current: 1, total: 2 });
m.handleEvent({ type: "annotation-error", current: 1, total: 2, message: "One failed" });
expect(m.state).toBe("processing");
});
});
-75
View File
@@ -1,75 +0,0 @@
import { describe, it, expect, vi } from "vitest";
import { AnthropicProvider } from "../../src/providers/anthropic";
function jsonResponse(status: number, headers: Record<string, string>, body: unknown) {
return {
status,
headers,
text: typeof body === "string" ? body : JSON.stringify(body),
json: typeof body === "string" ? undefined : body,
};
}
const successBody = {
content: [{ type: "text", text: "revised" }],
usage: { input_tokens: 5, output_tokens: 3 },
};
describe("AnthropicProvider retry", () => {
it("retries 429 then succeeds", async () => {
let n = 0;
const httpFn = vi.fn(async () => {
n += 1;
if (n < 3) return jsonResponse(429, { "retry-after": "0" }, { error: { message: "rate" } });
return jsonResponse(200, {}, successBody);
});
const provider = new AnthropicProvider(httpFn);
const result = await provider.complete({
systemPrompt: "s", userPrompt: "u", model: "claude-haiku-4-5",
maxTokens: 100, apiKey: "k", maxRetries: 3,
});
expect(result.text).toBe("revised");
expect(httpFn).toHaveBeenCalledTimes(3);
});
it("does not retry on 401", async () => {
const httpFn = vi.fn(async () => jsonResponse(401, {}, { error: { message: "auth" } }));
const provider = new AnthropicProvider(httpFn);
await expect(
provider.complete({
systemPrompt: "s", userPrompt: "u", model: "claude-haiku-4-5",
maxTokens: 100, apiKey: "k", maxRetries: 3,
}),
).rejects.toThrow(/401/);
expect(httpFn).toHaveBeenCalledTimes(1);
});
it("calls onRetry between attempts", async () => {
let n = 0;
const httpFn = async () => {
n += 1;
if (n < 2) return jsonResponse(503, { "retry-after": "0" }, { error: { message: "svc" } });
return jsonResponse(200, {}, successBody);
};
const onRetry = vi.fn();
const provider = new AnthropicProvider(httpFn);
await provider.complete({
systemPrompt: "s", userPrompt: "u", model: "claude-haiku-4-5",
maxTokens: 100, apiKey: "k", maxRetries: 3, onRetry,
});
expect(onRetry).toHaveBeenCalledTimes(1);
expect(onRetry.mock.calls[0][0]).toMatchObject({ attempt: 2, reason: "HTTP 503" });
});
it("maxRetries: 0 disables retry (single attempt only)", async () => {
const httpFn = vi.fn(async () => jsonResponse(503, {}, { error: { message: "svc" } }));
const provider = new AnthropicProvider(httpFn);
await expect(
provider.complete({
systemPrompt: "s", userPrompt: "u", model: "claude-haiku-4-5",
maxTokens: 100, apiKey: "k", maxRetries: 0,
}),
).rejects.toThrow(/503/);
expect(httpFn).toHaveBeenCalledTimes(1);
});
});
+6 -108
View File
@@ -92,7 +92,7 @@ describe("AnthropicProvider", () => {
const opus = models.find((m) => m.id === "claude-opus-4-6");
expect(opus).toBeDefined();
expect(opus!.contextWindow).toBe(1000000);
expect(opus!.contextWindow).toBe(200000);
expect(opus!.costPer1kInput).toBeGreaterThan(0);
expect(opus!.costPer1kOutput).toBeGreaterThan(0);
});
@@ -188,32 +188,6 @@ describe("AnthropicProvider", () => {
expect(headers["x-api-key"]).toBe("sk-test-key-123");
expect(headers["anthropic-version"]).toBe("2023-06-01");
expect(headers["content-type"]).toBe("application/json");
// No beta header when thinking is not enabled
expect(headers["anthropic-beta"]).toBeUndefined();
});
it("includes anthropic-beta header when thinking is enabled", async () => {
const responseText = anthropicResponse("OK");
const { fn, calls } = mockHttp({ text: responseText });
const provider = new AnthropicProvider(fn);
await provider.complete(makeRequest({ model: "claude-opus-4-6", useThinking: true }));
expect(calls).toHaveLength(1);
const headers = calls[0].headers!;
expect(headers["anthropic-beta"]).toBe("interleaved-thinking-2025-05-14");
});
it("does NOT include anthropic-beta header when thinking is disabled", async () => {
const responseText = anthropicResponse("OK");
const { fn, calls } = mockHttp({ text: responseText });
const provider = new AnthropicProvider(fn);
await provider.complete(makeRequest({ model: "claude-opus-4-6", useThinking: false }));
expect(calls).toHaveLength(1);
const headers = calls[0].headers!;
expect(headers["anthropic-beta"]).toBeUndefined();
});
it("calls the correct API URL", async () => {
@@ -296,18 +270,6 @@ describe("AnthropicProvider", () => {
expect(body.model).toBe("claude-opus-4-6");
});
it("preserves leading and trailing whitespace in the response (#16)", async () => {
// Authors rely on intentional whitespace as part of vertical rhythm.
// Provider parseResponse must not silently .trim() the model output.
const responseText = anthropicResponse("\n the door slammed. \n");
const { fn } = mockHttp({ text: responseText });
const provider = new AnthropicProvider(fn);
const result = await provider.complete(makeRequest());
expect(result.text).toBe("\n the door slammed. \n");
});
it("throws on 400 response with error message", async () => {
const errorResponse = JSON.stringify({
error: { type: "invalid_request_error", message: "max_tokens must be positive" },
@@ -338,9 +300,8 @@ describe("AnthropicProvider", () => {
const { fn } = mockHttp({ status: 429, text: errorResponse });
const provider = new AnthropicProvider(fn);
// maxRetries: 0 keeps this single-attempt; retry behavior covered separately.
await expect(provider.complete(makeRequest({ maxRetries: 0 }))).rejects.toThrow("Rate limited");
await expect(provider.complete(makeRequest({ maxRetries: 0 }))).rejects.toThrow("429");
await expect(provider.complete(makeRequest())).rejects.toThrow("Rate limited");
await expect(provider.complete(makeRequest())).rejects.toThrow("429");
});
it("throws on 500 response with server error text", async () => {
@@ -350,15 +311,15 @@ describe("AnthropicProvider", () => {
const { fn } = mockHttp({ status: 500, text: errorResponse });
const provider = new AnthropicProvider(fn);
await expect(provider.complete(makeRequest({ maxRetries: 0 }))).rejects.toThrow("Anthropic API error (500)");
await expect(provider.complete(makeRequest({ maxRetries: 0 }))).rejects.toThrow("Internal server error");
await expect(provider.complete(makeRequest())).rejects.toThrow("Anthropic API error (500)");
await expect(provider.complete(makeRequest())).rejects.toThrow("Internal server error");
});
it("throws with raw text when error response is not valid JSON", async () => {
const { fn } = mockHttp({ status: 502, text: "Bad Gateway" });
const provider = new AnthropicProvider(fn);
await expect(provider.complete(makeRequest({ maxRetries: 0 }))).rejects.toThrow("Anthropic API error (502): Bad Gateway");
await expect(provider.complete(makeRequest())).rejects.toThrow("Anthropic API error (502): Bad Gateway");
});
});
@@ -393,69 +354,6 @@ describe("AnthropicProvider", () => {
});
});
describe("streaming fallback", () => {
it("uses httpFn path when onToken is NOT provided", async () => {
const responseText = anthropicResponse("Non-streaming result.");
const { fn, calls } = mockHttp({ text: responseText });
const provider = new AnthropicProvider(fn);
const result = await provider.complete(makeRequest());
// Should use httpFn, not fetch
expect(calls).toHaveLength(1);
expect(result.text).toBe("Non-streaming result.");
expect(result.provider).toBe("anthropic");
});
it("falls back to httpFn when onToken is set but globalThis.fetch is unavailable", async () => {
// In the vitest environment, globalThis.fetch may or may not exist.
// Save and remove it to test the fallback path.
const originalFetch = globalThis.fetch;
try {
// @ts-expect-error -- deliberately removing fetch to test fallback
globalThis.fetch = undefined;
const responseText = anthropicResponse("Fallback result.");
const { fn, calls } = mockHttp({ text: responseText });
const provider = new AnthropicProvider(fn);
const tokens: string[] = [];
const result = await provider.complete(
makeRequest({ onToken: (t) => tokens.push(t) }),
);
// Should fall back to httpFn since fetch is not available
expect(calls).toHaveLength(1);
expect(result.text).toBe("Fallback result.");
// onToken should NOT have been called (non-streaming path)
expect(tokens).toHaveLength(0);
} finally {
globalThis.fetch = originalFetch;
}
});
it("accepts onToken in the request interface without errors", async () => {
// When fetch is unavailable, onToken is accepted but the httpFn path is used
const originalFetch = globalThis.fetch;
try {
// @ts-expect-error -- deliberately removing fetch to test fallback
globalThis.fetch = undefined;
const responseText = anthropicResponse("OK");
const { fn } = mockHttp({ text: responseText });
const provider = new AnthropicProvider(fn);
// Passing onToken should not cause any type or runtime error
const result = await provider.complete(
makeRequest({ onToken: () => {} }),
);
expect(result.text).toBe("OK");
} finally {
globalThis.fetch = originalFetch;
}
});
});
describe("testConnection", () => {
it("returns null on successful connection", async () => {
const responseText = anthropicResponse("pong");
-63
View File
@@ -1,63 +0,0 @@
import { describe, it, expect, vi } from "vitest";
import { GoogleProvider } from "../../src/providers/google";
function jsonResponse(status: number, headers: Record<string, string>, body: unknown) {
return {
status,
headers,
text: typeof body === "string" ? body : JSON.stringify(body),
json: typeof body === "string" ? undefined : body,
};
}
const successBody = {
candidates: [{ content: { parts: [{ text: "revised" }] } }],
usageMetadata: { promptTokenCount: 5, candidatesTokenCount: 3 },
};
describe("GoogleProvider retry", () => {
it("retries 429 then succeeds", async () => {
let n = 0;
const httpFn = vi.fn(async () => {
n += 1;
if (n < 3) return jsonResponse(429, { "retry-after": "0" }, { error: { message: "rate" } });
return jsonResponse(200, {}, successBody);
});
const provider = new GoogleProvider(httpFn);
const result = await provider.complete({
systemPrompt: "s", userPrompt: "u", model: "gemini-2.0-flash",
maxTokens: 100, apiKey: "k", maxRetries: 3,
});
expect(result.text).toBe("revised");
expect(httpFn).toHaveBeenCalledTimes(3);
});
it("does not retry on 401", async () => {
const httpFn = vi.fn(async () => jsonResponse(401, {}, { error: { message: "auth" } }));
const provider = new GoogleProvider(httpFn);
await expect(
provider.complete({
systemPrompt: "s", userPrompt: "u", model: "gemini-2.0-flash",
maxTokens: 100, apiKey: "k", maxRetries: 3,
}),
).rejects.toThrow(/401/);
expect(httpFn).toHaveBeenCalledTimes(1);
});
it("calls onRetry between attempts", async () => {
let n = 0;
const httpFn = async () => {
n += 1;
if (n < 2) return jsonResponse(503, { "retry-after": "0" }, { error: { message: "svc" } });
return jsonResponse(200, {}, successBody);
};
const onRetry = vi.fn();
const provider = new GoogleProvider(httpFn);
await provider.complete({
systemPrompt: "s", userPrompt: "u", model: "gemini-2.0-flash",
maxTokens: 100, apiKey: "k", maxRetries: 3, onRetry,
});
expect(onRetry).toHaveBeenCalledTimes(1);
expect(onRetry.mock.calls[0][0]).toMatchObject({ attempt: 2, reason: "HTTP 503" });
});
});
-392
View File
@@ -1,392 +0,0 @@
import { describe, it, expect, vi } from "vitest";
import { GoogleProvider } from "../../src/providers/google";
import type { HttpFn, HttpResponse, CompletionRequest } from "../../src/providers/service";
/** Create a mock HTTP function that records calls and returns a canned response. */
function mockHttp(response: Partial<HttpResponse> = {}): {
fn: HttpFn;
calls: Array<{ url: string; method?: string; headers?: Record<string, string>; body?: string }>;
} {
const calls: Array<{ url: string; method?: string; headers?: Record<string, string>; body?: string }> = [];
const fn: HttpFn = async (params) => {
calls.push({
url: params.url,
method: params.method,
headers: params.headers,
body: params.body,
});
return {
status: response.status ?? 200,
headers: response.headers ?? {},
text: response.text ?? "{}",
json: response.json ?? {},
};
};
return { fn, calls };
}
/** Build a standard Google Generative Language API response body. */
function googleResponse(text: string, opts?: {
promptTokenCount?: number;
candidatesTokenCount?: number;
}): string {
return JSON.stringify({
candidates: [
{
content: {
parts: [{ text }],
role: "model",
},
},
],
usageMetadata: {
promptTokenCount: opts?.promptTokenCount ?? 100,
candidatesTokenCount: opts?.candidatesTokenCount ?? 50,
},
});
}
function makeRequest(overrides: Partial<CompletionRequest> = {}): CompletionRequest {
return {
systemPrompt: "You are a writing assistant.",
userPrompt: "Rewrite this passage with more tension.",
model: "gemini-2.5-flash",
maxTokens: 4096,
apiKey: "mock-google-test-key-123",
...overrides,
};
}
describe("GoogleProvider", () => {
describe("metadata", () => {
it("has name 'google'", () => {
const { fn } = mockHttp();
const provider = new GoogleProvider(fn);
expect(provider.name).toBe("google");
});
it("requires an API key", () => {
const { fn } = mockHttp();
const provider = new GoogleProvider(fn);
expect(provider.requiresApiKey).toBe(true);
});
});
describe("getModels", () => {
it("returns static list of Gemini models", async () => {
const { fn } = mockHttp();
const provider = new GoogleProvider(fn);
const models = await provider.getModels();
expect(models.length).toBe(3);
const ids = models.map((m) => m.id);
expect(ids).toContain("gemini-2.5-pro");
expect(ids).toContain("gemini-2.5-flash");
expect(ids).toContain("gemini-2.0-flash");
});
it("includes context window info", async () => {
const { fn } = mockHttp();
const provider = new GoogleProvider(fn);
const models = await provider.getModels();
const pro = models.find((m) => m.id === "gemini-2.5-pro");
expect(pro).toBeDefined();
expect(pro!.contextWindow).toBe(1048576);
});
});
describe("complete", () => {
it("sends correct request body format", async () => {
const responseText = googleResponse("Revised passage.");
const { fn, calls } = mockHttp({ text: responseText });
const provider = new GoogleProvider(fn);
await provider.complete(makeRequest());
expect(calls).toHaveLength(1);
const body = JSON.parse(calls[0].body!);
expect(body.contents).toHaveLength(1);
expect(body.contents[0].role).toBe("user");
expect(body.contents[0].parts[0].text).toBe("Rewrite this passage with more tension.");
expect(body.systemInstruction).toBeDefined();
expect(body.systemInstruction.parts[0].text).toBe("You are a writing assistant.");
expect(body.generationConfig.maxOutputTokens).toBe(4096);
});
it("calls the correct API URL with model and key", async () => {
const responseText = googleResponse("OK");
const { fn, calls } = mockHttp({ text: responseText });
const provider = new GoogleProvider(fn);
await provider.complete(makeRequest());
expect(calls[0].url).toBe(
"https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash:generateContent?key=mock-google-test-key-123"
);
});
it("sends correct headers", async () => {
const responseText = googleResponse("OK");
const { fn, calls } = mockHttp({ text: responseText });
const provider = new GoogleProvider(fn);
await provider.complete(makeRequest());
expect(calls).toHaveLength(1);
const headers = calls[0].headers!;
expect(headers["content-type"]).toBe("application/json");
});
it("extracts text from a standard response", async () => {
const responseText = googleResponse("The door slammed shut.");
const { fn } = mockHttp({ text: responseText });
const provider = new GoogleProvider(fn);
const result = await provider.complete(makeRequest());
expect(result.text).toBe("The door slammed shut.");
expect(result.provider).toBe("google");
expect(result.model).toBe("gemini-2.5-flash");
});
it("parses usage information", async () => {
const responseText = googleResponse("Output.", {
promptTokenCount: 500,
candidatesTokenCount: 200,
});
const { fn } = mockHttp({ text: responseText });
const provider = new GoogleProvider(fn);
const result = await provider.complete(makeRequest());
expect(result.usage).toBeDefined();
expect(result.usage!.inputTokens).toBe(500);
expect(result.usage!.outputTokens).toBe(200);
});
it("returns empty text when response has no candidates", async () => {
const responseText = JSON.stringify({
candidates: [],
usageMetadata: { promptTokenCount: 10, candidatesTokenCount: 0 },
});
const { fn } = mockHttp({ text: responseText });
const provider = new GoogleProvider(fn);
const result = await provider.complete(makeRequest());
expect(result.text).toBe("");
});
it("throws if no API key is provided", async () => {
const { fn } = mockHttp();
const provider = new GoogleProvider(fn);
await expect(
provider.complete(makeRequest({ apiKey: undefined })),
).rejects.toThrow("Google API key is required");
});
it("uses the model from the request", async () => {
const responseText = googleResponse("ok");
const { fn, calls } = mockHttp({ text: responseText });
const provider = new GoogleProvider(fn);
await provider.complete(makeRequest({ model: "gemini-2.5-pro" }));
expect(calls[0].url).toContain("gemini-2.5-pro:generateContent");
});
it("throws on 400 response with error message", async () => {
const errorResponse = JSON.stringify({
error: { code: 400, message: "Invalid request", status: "INVALID_ARGUMENT" },
});
const { fn } = mockHttp({ status: 400, text: errorResponse });
const provider = new GoogleProvider(fn);
await expect(provider.complete(makeRequest())).rejects.toThrow(
"Google API error (400): Invalid request"
);
});
it("throws 'Invalid API key' on 401 response", async () => {
const errorResponse = JSON.stringify({
error: { code: 401, message: "API key not valid", status: "UNAUTHENTICATED" },
});
const { fn } = mockHttp({ status: 401, text: errorResponse });
const provider = new GoogleProvider(fn);
await expect(provider.complete(makeRequest())).rejects.toThrow("Invalid API key");
await expect(provider.complete(makeRequest())).rejects.toThrow("401");
});
it("throws 'Rate limited' on 429 response", async () => {
const errorResponse = JSON.stringify({
error: { code: 429, message: "Resource exhausted", status: "RESOURCE_EXHAUSTED" },
});
const { fn } = mockHttp({ status: 429, text: errorResponse });
const provider = new GoogleProvider(fn);
// maxRetries: 0 keeps this single-attempt; retry behavior covered separately.
await expect(provider.complete(makeRequest({ maxRetries: 0 }))).rejects.toThrow("Rate limited");
await expect(provider.complete(makeRequest({ maxRetries: 0 }))).rejects.toThrow("429");
});
it("throws on 500 response with server error text", async () => {
const errorResponse = JSON.stringify({
error: { code: 500, message: "Internal error", status: "INTERNAL" },
});
const { fn } = mockHttp({ status: 500, text: errorResponse });
const provider = new GoogleProvider(fn);
await expect(provider.complete(makeRequest({ maxRetries: 0 }))).rejects.toThrow("Google API error (500)");
await expect(provider.complete(makeRequest({ maxRetries: 0 }))).rejects.toThrow("Internal error");
});
it("throws with raw text when error response is not valid JSON", async () => {
const { fn } = mockHttp({ status: 502, text: "Bad Gateway" });
const provider = new GoogleProvider(fn);
await expect(provider.complete(makeRequest({ maxRetries: 0 }))).rejects.toThrow("Google API error (502): Bad Gateway");
});
});
describe("streaming fallback", () => {
it("uses httpFn path when onToken is NOT provided", async () => {
const responseText = googleResponse("Non-streaming result.");
const { fn, calls } = mockHttp({ text: responseText });
const provider = new GoogleProvider(fn);
const result = await provider.complete(makeRequest());
expect(calls).toHaveLength(1);
expect(result.text).toBe("Non-streaming result.");
expect(result.provider).toBe("google");
});
it("falls back to httpFn when onToken is set but globalThis.fetch is unavailable", async () => {
const originalFetch = globalThis.fetch;
try {
// @ts-expect-error -- deliberately removing fetch to test fallback
globalThis.fetch = undefined;
const responseText = googleResponse("Fallback result.");
const { fn, calls } = mockHttp({ text: responseText });
const provider = new GoogleProvider(fn);
const tokens: string[] = [];
const result = await provider.complete(
makeRequest({ onToken: (t) => tokens.push(t) }),
);
expect(calls).toHaveLength(1);
expect(result.text).toBe("Fallback result.");
expect(tokens).toHaveLength(0);
} finally {
globalThis.fetch = originalFetch;
}
});
it("accepts onToken in the request interface without errors", async () => {
const originalFetch = globalThis.fetch;
try {
// @ts-expect-error -- deliberately removing fetch to test fallback
globalThis.fetch = undefined;
const responseText = googleResponse("OK");
const { fn } = mockHttp({ text: responseText });
const provider = new GoogleProvider(fn);
const result = await provider.complete(
makeRequest({ onToken: () => {} }),
);
expect(result.text).toBe("OK");
} finally {
globalThis.fetch = originalFetch;
}
});
});
describe("estimateTokens", () => {
it("estimates tokens as words * 1.0", () => {
const { fn } = mockHttp();
const provider = new GoogleProvider(fn);
// 10 words
const text = "one two three four five six seven eight nine ten";
const estimate = provider.estimateTokens(text);
expect(estimate).toBe(10);
});
it("handles empty text", () => {
const { fn } = mockHttp();
const provider = new GoogleProvider(fn);
expect(provider.estimateTokens("")).toBe(0);
});
it("handles single word", () => {
const { fn } = mockHttp();
const provider = new GoogleProvider(fn);
expect(provider.estimateTokens("hello")).toBe(1);
});
it("ignores extra whitespace in word count", () => {
const { fn } = mockHttp();
const provider = new GoogleProvider(fn);
const text = " spaced out text ";
expect(provider.estimateTokens(text)).toBe(3);
});
});
describe("testConnection", () => {
it("returns null on successful connection", async () => {
const responseText = googleResponse("pong");
const { fn } = mockHttp({ status: 200, text: responseText });
const provider = new GoogleProvider(fn);
const result = await provider.testConnection({ apiKey: "mock-google-valid" });
expect(result).toBeNull();
});
it("returns error message when API key is missing", async () => {
const { fn } = mockHttp();
const provider = new GoogleProvider(fn);
const result = await provider.testConnection({});
expect(result).toBe("Google API key is required");
});
it("returns error message on non-200 status", async () => {
const { fn } = mockHttp({ status: 401, text: '{"error":{"message":"invalid key"}}' });
const provider = new GoogleProvider(fn);
const result = await provider.testConnection({ apiKey: "mock-google-bad" });
expect(result).toContain("401");
});
it("returns error message on network failure", async () => {
const fn: HttpFn = async () => {
throw new Error("Network error");
};
const provider = new GoogleProvider(fn);
const result = await provider.testConnection({ apiKey: "mock-google-key" });
expect(result).toContain("Network error");
});
it("sends a minimal request to verify the key", async () => {
const responseText = googleResponse("ok");
const { fn, calls } = mockHttp({ status: 200, text: responseText });
const provider = new GoogleProvider(fn);
await provider.testConnection({ apiKey: "mock-google-test" });
expect(calls).toHaveLength(1);
expect(calls[0].url).toContain("key=mock-google-test");
expect(calls[0].url).toContain("gemini-2.0-flash:generateContent");
const body = JSON.parse(calls[0].body!);
expect(body.generationConfig.maxOutputTokens).toBe(16);
});
});
});
-39
View File
@@ -1,39 +0,0 @@
import { describe, it, expect } from "vitest";
import { createRegistry } from "../../src/providers";
import type { HttpFn } from "../../src/providers/service";
describe("createRegistry", () => {
// Stub HttpFn — never called by registry construction; tests only assert wiring.
const stubHttp: HttpFn = async () => ({
status: 200,
headers: {},
text: "{}",
json: {},
});
it("returns a registry pre-populated with all built-in providers", () => {
const reg = createRegistry(stubHttp);
const names = reg.getNames().sort();
expect(names).toEqual(["anthropic", "google", "ollama", "openai"]);
});
it("each registered provider implements the LLMService interface", () => {
const reg = createRegistry(stubHttp);
for (const provider of reg.getAll()) {
expect(typeof provider.name).toBe("string");
expect(typeof provider.requiresApiKey).toBe("boolean");
expect(typeof provider.complete).toBe("function");
expect(typeof provider.estimateTokens).toBe("function");
expect(typeof provider.testConnection).toBe("function");
expect(typeof provider.getModels).toBe("function");
}
});
it("Anthropic, Google, OpenAI require API keys; Ollama does not", () => {
const reg = createRegistry(stubHttp);
expect(reg.get("anthropic")?.requiresApiKey).toBe(true);
expect(reg.get("google")?.requiresApiKey).toBe(true);
expect(reg.get("openai")?.requiresApiKey).toBe(true);
expect(reg.get("ollama")?.requiresApiKey).toBe(false);
});
});
-58
View File
@@ -1,58 +0,0 @@
import { describe, it, expect, vi } from "vitest";
import { OllamaProvider } from "../../src/providers/ollama";
function jsonResponse(status: number, headers: Record<string, string>, body: unknown) {
return {
status,
headers,
text: typeof body === "string" ? body : JSON.stringify(body),
json: typeof body === "string" ? undefined : body,
};
}
const successBody = {
choices: [{ message: { content: "revised" } }],
usage: { prompt_tokens: 5, completion_tokens: 3 },
};
describe("OllamaProvider retry", () => {
it("retries 503 then succeeds", async () => {
let n = 0;
const httpFn = vi.fn(async () => {
n += 1;
if (n < 3) return jsonResponse(503, { "retry-after": "0" }, { error: "loading model" });
return jsonResponse(200, {}, successBody);
});
const provider = new OllamaProvider(httpFn);
const result = await provider.complete({
systemPrompt: "s", userPrompt: "u", model: "llama3.2",
maxTokens: 100, endpoint: "http://localhost:11434", maxRetries: 3,
});
expect(result.text).toBe("revised");
expect(httpFn).toHaveBeenCalledTimes(3);
});
it("does not retry on 404 (model not found)", async () => {
const httpFn = vi.fn(async () => jsonResponse(404, {}, { error: "model not found" }));
const provider = new OllamaProvider(httpFn);
await expect(
provider.complete({
systemPrompt: "s", userPrompt: "u", model: "missing-model",
maxTokens: 100, endpoint: "http://localhost:11434", maxRetries: 3,
}),
).rejects.toThrow(/404/);
expect(httpFn).toHaveBeenCalledTimes(1);
});
it("maxRetries: 0 disables retry on 503", async () => {
const httpFn = vi.fn(async () => jsonResponse(503, {}, { error: "busy" }));
const provider = new OllamaProvider(httpFn);
await expect(
provider.complete({
systemPrompt: "s", userPrompt: "u", model: "llama3.2",
maxTokens: 100, endpoint: "http://localhost:11434", maxRetries: 0,
}),
).rejects.toThrow(/503/);
expect(httpFn).toHaveBeenCalledTimes(1);
});
});
+5 -72
View File
@@ -1,4 +1,4 @@
import { describe, it, expect, vi } from "vitest";
import { describe, it, expect } from "vitest";
import { OllamaProvider } from "../../src/providers/ollama";
import type { HttpFn, HttpResponse, CompletionRequest } from "../../src/providers/service";
@@ -278,85 +278,18 @@ describe("OllamaProvider", () => {
const { fn } = mockHttp({ status: 500, text: "Internal Server Error" });
const provider = new OllamaProvider(fn);
// maxRetries: 0 keeps this single-attempt; retry behavior covered separately.
await expect(provider.complete(makeRequest({ maxRetries: 0 }))).rejects.toThrow("Ollama error (500): Internal Server Error");
await expect(provider.complete(makeRequest())).rejects.toThrow("Ollama error (500): Internal Server Error");
});
it("throws connection refused message when Ollama is not running", async () => {
let attempts = 0;
const fn: HttpFn = async () => {
attempts++;
throw new Error("ECONNREFUSED");
};
const provider = new OllamaProvider(fn);
// Zero the retry jitter: the default 3 retries back off up to 1+2+4 s at random,
// which intermittently exceeded the 5 s test timeout.
const random = vi.spyOn(Math, "random").mockReturnValue(0);
try {
await expect(
provider.complete(makeRequest()),
).rejects.toThrow("Cannot connect to Ollama at http://localhost:11434. Is Ollama running?");
expect(attempts).toBe(4);
} finally {
random.mockRestore();
}
});
});
describe("streaming fallback", () => {
it("uses httpFn path when onToken is NOT provided", async () => {
const responseText = ollamaResponse("Non-streaming result.");
const { fn, calls } = mockHttp({ text: responseText });
const provider = new OllamaProvider(fn);
const result = await provider.complete(makeRequest());
expect(calls).toHaveLength(1);
expect(result.text).toBe("Non-streaming result.");
expect(result.provider).toBe("ollama");
});
it("falls back to httpFn when onToken is set but globalThis.fetch is unavailable", async () => {
const originalFetch = globalThis.fetch;
try {
// @ts-expect-error -- deliberately removing fetch to test fallback
globalThis.fetch = undefined;
const responseText = ollamaResponse("Fallback result.");
const { fn, calls } = mockHttp({ text: responseText });
const provider = new OllamaProvider(fn);
const tokens: string[] = [];
const result = await provider.complete(
makeRequest({ onToken: (t) => tokens.push(t) }),
);
expect(calls).toHaveLength(1);
expect(result.text).toBe("Fallback result.");
expect(tokens).toHaveLength(0);
} finally {
globalThis.fetch = originalFetch;
}
});
it("accepts onToken in the request interface without errors", async () => {
const originalFetch = globalThis.fetch;
try {
// @ts-expect-error -- deliberately removing fetch to test fallback
globalThis.fetch = undefined;
const responseText = ollamaResponse("OK");
const { fn } = mockHttp({ text: responseText });
const provider = new OllamaProvider(fn);
const result = await provider.complete(
makeRequest({ onToken: () => {} }),
);
expect(result.text).toBe("OK");
} finally {
globalThis.fetch = originalFetch;
}
await expect(
provider.complete(makeRequest()),
).rejects.toThrow("Cannot connect to Ollama at http://localhost:11434. Is Ollama running?");
});
});
-63
View File
@@ -1,63 +0,0 @@
import { describe, it, expect, vi } from "vitest";
import { OpenAIProvider } from "../../src/providers/openai";
function jsonResponse(status: number, headers: Record<string, string>, body: unknown) {
return {
status,
headers,
text: typeof body === "string" ? body : JSON.stringify(body),
json: typeof body === "string" ? undefined : body,
};
}
const successBody = {
choices: [{ message: { content: "revised" } }],
usage: { prompt_tokens: 5, completion_tokens: 3 },
};
describe("OpenAIProvider retry", () => {
it("retries 429 then succeeds", async () => {
let n = 0;
const httpFn = vi.fn(async () => {
n += 1;
if (n < 3) return jsonResponse(429, { "retry-after": "0" }, { error: { message: "rate" } });
return jsonResponse(200, {}, successBody);
});
const provider = new OpenAIProvider(httpFn);
const result = await provider.complete({
systemPrompt: "s", userPrompt: "u", model: "gpt-4o-mini",
maxTokens: 100, apiKey: "k", maxRetries: 3,
});
expect(result.text).toBe("revised");
expect(httpFn).toHaveBeenCalledTimes(3);
});
it("does not retry on 401", async () => {
const httpFn = vi.fn(async () => jsonResponse(401, {}, { error: { message: "auth" } }));
const provider = new OpenAIProvider(httpFn);
await expect(
provider.complete({
systemPrompt: "s", userPrompt: "u", model: "gpt-4o-mini",
maxTokens: 100, apiKey: "k", maxRetries: 3,
}),
).rejects.toThrow(/401/);
expect(httpFn).toHaveBeenCalledTimes(1);
});
it("calls onRetry between attempts", async () => {
let n = 0;
const httpFn = async () => {
n += 1;
if (n < 2) return jsonResponse(503, { "retry-after": "0" }, { error: { message: "svc" } });
return jsonResponse(200, {}, successBody);
};
const onRetry = vi.fn();
const provider = new OpenAIProvider(httpFn);
await provider.complete({
systemPrompt: "s", userPrompt: "u", model: "gpt-4o-mini",
maxTokens: 100, apiKey: "k", maxRetries: 3, onRetry,
});
expect(onRetry).toHaveBeenCalledTimes(1);
expect(onRetry.mock.calls[0][0]).toMatchObject({ attempt: 2, reason: "HTTP 503" });
});
});
-402
View File
@@ -1,402 +0,0 @@
import { describe, it, expect, vi } from "vitest";
import { OpenAIProvider } from "../../src/providers/openai";
import type { HttpFn, HttpResponse, CompletionRequest } from "../../src/providers/service";
/** Create a mock HTTP function that records calls and returns a canned response. */
function mockHttp(response: Partial<HttpResponse> = {}): {
fn: HttpFn;
calls: Array<{ url: string; method?: string; headers?: Record<string, string>; body?: string }>;
} {
const calls: Array<{ url: string; method?: string; headers?: Record<string, string>; body?: string }> = [];
const fn: HttpFn = async (params) => {
calls.push({
url: params.url,
method: params.method,
headers: params.headers,
body: params.body,
});
return {
status: response.status ?? 200,
headers: response.headers ?? {},
text: response.text ?? "{}",
json: response.json ?? {},
};
};
return { fn, calls };
}
/** Build a standard OpenAI Chat Completions API response body. */
function openaiResponse(text: string, opts?: {
promptTokens?: number;
completionTokens?: number;
model?: string;
}): string {
return JSON.stringify({
choices: [
{
message: {
role: "assistant",
content: text,
},
finish_reason: "stop",
index: 0,
},
],
usage: {
prompt_tokens: opts?.promptTokens ?? 100,
completion_tokens: opts?.completionTokens ?? 50,
},
model: opts?.model ?? "gpt-4.1",
});
}
function makeRequest(overrides: Partial<CompletionRequest> = {}): CompletionRequest {
return {
systemPrompt: "You are a writing assistant.",
userPrompt: "Rewrite this passage with more tension.",
model: "gpt-4.1",
maxTokens: 4096,
apiKey: "sk-test-key-123",
...overrides,
};
}
describe("OpenAIProvider", () => {
describe("metadata", () => {
it("has name 'openai'", () => {
const { fn } = mockHttp();
const provider = new OpenAIProvider(fn);
expect(provider.name).toBe("openai");
});
it("requires an API key", () => {
const { fn } = mockHttp();
const provider = new OpenAIProvider(fn);
expect(provider.requiresApiKey).toBe(true);
});
});
describe("getModels", () => {
it("returns static list of OpenAI models", async () => {
const { fn } = mockHttp();
const provider = new OpenAIProvider(fn);
const models = await provider.getModels();
expect(models.length).toBe(5);
const ids = models.map((m) => m.id);
expect(ids).toContain("gpt-4.1");
expect(ids).toContain("gpt-4.1-mini");
expect(ids).toContain("gpt-4.1-nano");
expect(ids).toContain("o3");
expect(ids).toContain("o4-mini");
});
it("includes context window info", async () => {
const { fn } = mockHttp();
const provider = new OpenAIProvider(fn);
const models = await provider.getModels();
const gpt41 = models.find((m) => m.id === "gpt-4.1");
expect(gpt41).toBeDefined();
expect(gpt41!.contextWindow).toBe(1047576);
const o3 = models.find((m) => m.id === "o3");
expect(o3).toBeDefined();
expect(o3!.contextWindow).toBe(200000);
});
});
describe("complete", () => {
it("sends correct request body format", async () => {
const responseText = openaiResponse("Revised passage.");
const { fn, calls } = mockHttp({ text: responseText });
const provider = new OpenAIProvider(fn);
await provider.complete(makeRequest());
expect(calls).toHaveLength(1);
const body = JSON.parse(calls[0].body!);
expect(body.model).toBe("gpt-4.1");
expect(body.max_tokens).toBe(4096);
expect(body.messages).toHaveLength(2);
expect(body.messages[0].role).toBe("system");
expect(body.messages[0].content).toBe("You are a writing assistant.");
expect(body.messages[1].role).toBe("user");
expect(body.messages[1].content).toBe("Rewrite this passage with more tension.");
});
it("calls the correct API URL", async () => {
const responseText = openaiResponse("OK");
const { fn, calls } = mockHttp({ text: responseText });
const provider = new OpenAIProvider(fn);
await provider.complete(makeRequest());
expect(calls[0].url).toBe("https://api.openai.com/v1/chat/completions");
});
it("sends correct headers with Bearer auth", async () => {
const responseText = openaiResponse("OK");
const { fn, calls } = mockHttp({ text: responseText });
const provider = new OpenAIProvider(fn);
await provider.complete(makeRequest());
expect(calls).toHaveLength(1);
const headers = calls[0].headers!;
expect(headers["Authorization"]).toBe("Bearer sk-test-key-123");
expect(headers["content-type"]).toBe("application/json");
});
it("extracts text from a standard response", async () => {
const responseText = openaiResponse("The door slammed shut.");
const { fn } = mockHttp({ text: responseText });
const provider = new OpenAIProvider(fn);
const result = await provider.complete(makeRequest());
expect(result.text).toBe("The door slammed shut.");
expect(result.provider).toBe("openai");
expect(result.model).toBe("gpt-4.1");
});
it("parses usage information", async () => {
const responseText = openaiResponse("Output.", {
promptTokens: 500,
completionTokens: 200,
});
const { fn } = mockHttp({ text: responseText });
const provider = new OpenAIProvider(fn);
const result = await provider.complete(makeRequest());
expect(result.usage).toBeDefined();
expect(result.usage!.inputTokens).toBe(500);
expect(result.usage!.outputTokens).toBe(200);
});
it("returns empty text when response has no choices", async () => {
const responseText = JSON.stringify({
choices: [],
usage: { prompt_tokens: 10, completion_tokens: 0 },
});
const { fn } = mockHttp({ text: responseText });
const provider = new OpenAIProvider(fn);
const result = await provider.complete(makeRequest());
expect(result.text).toBe("");
});
it("throws if no API key is provided", async () => {
const { fn } = mockHttp();
const provider = new OpenAIProvider(fn);
await expect(
provider.complete(makeRequest({ apiKey: undefined })),
).rejects.toThrow("OpenAI API key is required");
});
it("uses the model from the request", async () => {
const responseText = openaiResponse("ok");
const { fn, calls } = mockHttp({ text: responseText });
const provider = new OpenAIProvider(fn);
await provider.complete(makeRequest({ model: "o3" }));
const body = JSON.parse(calls[0].body!);
expect(body.model).toBe("o3");
});
it("throws on 400 response with error message", async () => {
const errorResponse = JSON.stringify({
error: { message: "max_tokens must be positive", type: "invalid_request_error", code: null },
});
const { fn } = mockHttp({ status: 400, text: errorResponse });
const provider = new OpenAIProvider(fn);
await expect(provider.complete(makeRequest())).rejects.toThrow(
"OpenAI API error (400): max_tokens must be positive"
);
});
it("throws 'Invalid API key' on 401 response", async () => {
const errorResponse = JSON.stringify({
error: { message: "Incorrect API key provided", type: "authentication_error", code: "invalid_api_key" },
});
const { fn } = mockHttp({ status: 401, text: errorResponse });
const provider = new OpenAIProvider(fn);
await expect(provider.complete(makeRequest())).rejects.toThrow("Invalid API key");
await expect(provider.complete(makeRequest())).rejects.toThrow("401");
});
it("throws 'Rate limited' on 429 response", async () => {
const errorResponse = JSON.stringify({
error: { message: "Rate limit reached", type: "rate_limit_error", code: "rate_limit_exceeded" },
});
const { fn } = mockHttp({ status: 429, text: errorResponse });
const provider = new OpenAIProvider(fn);
// maxRetries: 0 keeps this single-attempt; retry behavior covered separately.
await expect(provider.complete(makeRequest({ maxRetries: 0 }))).rejects.toThrow("Rate limited");
await expect(provider.complete(makeRequest({ maxRetries: 0 }))).rejects.toThrow("429");
});
it("throws on 500 response with server error text", async () => {
const errorResponse = JSON.stringify({
error: { message: "Internal server error", type: "server_error", code: null },
});
const { fn } = mockHttp({ status: 500, text: errorResponse });
const provider = new OpenAIProvider(fn);
await expect(provider.complete(makeRequest({ maxRetries: 0 }))).rejects.toThrow("OpenAI API error (500)");
await expect(provider.complete(makeRequest({ maxRetries: 0 }))).rejects.toThrow("Internal server error");
});
it("throws with raw text when error response is not valid JSON", async () => {
const { fn } = mockHttp({ status: 502, text: "Bad Gateway" });
const provider = new OpenAIProvider(fn);
await expect(provider.complete(makeRequest({ maxRetries: 0 }))).rejects.toThrow("OpenAI API error (502): Bad Gateway");
});
});
describe("streaming fallback", () => {
it("uses httpFn path when onToken is NOT provided", async () => {
const responseText = openaiResponse("Non-streaming result.");
const { fn, calls } = mockHttp({ text: responseText });
const provider = new OpenAIProvider(fn);
const result = await provider.complete(makeRequest());
expect(calls).toHaveLength(1);
expect(result.text).toBe("Non-streaming result.");
expect(result.provider).toBe("openai");
});
it("falls back to httpFn when onToken is set but globalThis.fetch is unavailable", async () => {
const originalFetch = globalThis.fetch;
try {
// @ts-expect-error -- deliberately removing fetch to test fallback
globalThis.fetch = undefined;
const responseText = openaiResponse("Fallback result.");
const { fn, calls } = mockHttp({ text: responseText });
const provider = new OpenAIProvider(fn);
const tokens: string[] = [];
const result = await provider.complete(
makeRequest({ onToken: (t) => tokens.push(t) }),
);
expect(calls).toHaveLength(1);
expect(result.text).toBe("Fallback result.");
expect(tokens).toHaveLength(0);
} finally {
globalThis.fetch = originalFetch;
}
});
it("accepts onToken in the request interface without errors", async () => {
const originalFetch = globalThis.fetch;
try {
// @ts-expect-error -- deliberately removing fetch to test fallback
globalThis.fetch = undefined;
const responseText = openaiResponse("OK");
const { fn } = mockHttp({ text: responseText });
const provider = new OpenAIProvider(fn);
const result = await provider.complete(
makeRequest({ onToken: () => {} }),
);
expect(result.text).toBe("OK");
} finally {
globalThis.fetch = originalFetch;
}
});
});
describe("estimateTokens", () => {
it("estimates tokens as words * 1.0", () => {
const { fn } = mockHttp();
const provider = new OpenAIProvider(fn);
// 10 words
const text = "one two three four five six seven eight nine ten";
const estimate = provider.estimateTokens(text);
expect(estimate).toBe(10);
});
it("handles empty text", () => {
const { fn } = mockHttp();
const provider = new OpenAIProvider(fn);
expect(provider.estimateTokens("")).toBe(0);
});
it("handles single word", () => {
const { fn } = mockHttp();
const provider = new OpenAIProvider(fn);
expect(provider.estimateTokens("hello")).toBe(1);
});
it("ignores extra whitespace in word count", () => {
const { fn } = mockHttp();
const provider = new OpenAIProvider(fn);
const text = " spaced out text ";
expect(provider.estimateTokens(text)).toBe(3);
});
});
describe("testConnection", () => {
it("returns null on successful connection", async () => {
const modelsResponse = JSON.stringify({ data: [{ id: "gpt-4.1" }] });
const { fn } = mockHttp({ status: 200, text: modelsResponse });
const provider = new OpenAIProvider(fn);
const result = await provider.testConnection({ apiKey: "sk-valid" });
expect(result).toBeNull();
});
it("returns error message when API key is missing", async () => {
const { fn } = mockHttp();
const provider = new OpenAIProvider(fn);
const result = await provider.testConnection({});
expect(result).toBe("OpenAI API key is required");
});
it("returns error message on non-200 status", async () => {
const { fn } = mockHttp({ status: 401, text: '{"error":{"message":"invalid key"}}' });
const provider = new OpenAIProvider(fn);
const result = await provider.testConnection({ apiKey: "sk-bad" });
expect(result).toContain("401");
});
it("returns error message on network failure", async () => {
const fn: HttpFn = async () => {
throw new Error("Network error");
};
const provider = new OpenAIProvider(fn);
const result = await provider.testConnection({ apiKey: "sk-key" });
expect(result).toContain("Network error");
});
it("sends a GET request to /v1/models to verify the key", async () => {
const modelsResponse = JSON.stringify({ data: [{ id: "gpt-4.1" }] });
const { fn, calls } = mockHttp({ status: 200, text: modelsResponse });
const provider = new OpenAIProvider(fn);
await provider.testConnection({ apiKey: "sk-test" });
expect(calls).toHaveLength(1);
expect(calls[0].url).toBe("https://api.openai.com/v1/models");
expect(calls[0].method).toBe("GET");
expect(calls[0].headers!["Authorization"]).toBe("Bearer sk-test");
});
});
});
-254
View File
@@ -1,254 +0,0 @@
import { describe, it, expect, vi } from "vitest";
import { HttpError, isRetriable, parseRetryAfter, backoffDelay, withRetry } from "../../src/providers/node-stream";
describe("HttpError", () => {
it("captures status, headers, and body", () => {
const err = new HttpError(429, { "retry-after": "30" }, '{"error":"rate"}');
expect(err).toBeInstanceOf(Error);
expect(err.name).toBe("HttpError");
expect(err.status).toBe(429);
expect(err.headers["retry-after"]).toBe("30");
expect(err.body).toBe('{"error":"rate"}');
expect(err.message).toContain("429");
});
it("accepts a custom message", () => {
const err = new HttpError(500, {}, "boom", "Anthropic API error (500): boom");
expect(err.message).toBe("Anthropic API error (500): boom");
});
});
describe("isRetriable", () => {
it("retries 429", () => {
expect(isRetriable(new HttpError(429, {}, ""))).toBe(true);
});
it("retries 408", () => {
expect(isRetriable(new HttpError(408, {}, ""))).toBe(true);
});
it("retries all 5xx (500, 501, 502, 503, 504)", () => {
for (const s of [500, 501, 502, 503, 504]) {
expect(isRetriable(new HttpError(s, {}, ""))).toBe(true);
}
});
it("does not retry 4xx other than 429/408", () => {
for (const s of [400, 401, 403, 404, 422]) {
expect(isRetriable(new HttpError(s, {}, ""))).toBe(false);
}
});
it("does not retry AbortError", () => {
const e = new Error("aborted");
e.name = "AbortError";
expect(isRetriable(e)).toBe(false);
});
it("does not retry TimeoutError", () => {
const e = new Error("timed out");
e.name = "TimeoutError";
expect(isRetriable(e)).toBe(false);
});
it("retries plain Error (transport-level failure)", () => {
expect(isRetriable(new Error("ECONNRESET"))).toBe(true);
});
it("retries non-Error throws (treats unknown as retriable transport noise)", () => {
expect(isRetriable("network down")).toBe(true);
});
});
describe("parseRetryAfter", () => {
it("parses delta-seconds (number)", () => {
expect(parseRetryAfter("30")).toBe(30_000);
expect(parseRetryAfter("0")).toBe(0);
});
it("parses HTTP-date and returns ms-from-now (clamped to >= 0)", () => {
const future = new Date(Date.now() + 10_000).toUTCString();
const parsed = parseRetryAfter(future);
expect(parsed).not.toBeNull();
expect(parsed!).toBeGreaterThan(5_000);
expect(parsed!).toBeLessThanOrEqual(10_000);
const past = new Date(Date.now() - 60_000).toUTCString();
expect(parseRetryAfter(past)).toBe(0);
});
it("returns null for malformed values", () => {
expect(parseRetryAfter("abc")).toBeNull();
expect(parseRetryAfter("")).toBeNull();
expect(parseRetryAfter(undefined)).toBeNull();
expect(parseRetryAfter("-5")).toBeNull();
});
});
describe("backoffDelay", () => {
it("honors Retry-After header (seconds), capped at 60s", () => {
const err = new HttpError(429, { "retry-after": "30" }, "");
expect(backoffDelay(err, 0)).toBe(30_000);
});
it("caps Retry-After at 60s for a misformatted long delay", () => {
const err = new HttpError(429, { "retry-after": "86400" }, "");
expect(backoffDelay(err, 0)).toBe(60_000);
});
it("falls back to exponential with full jitter on malformed Retry-After", () => {
const err = new HttpError(429, { "retry-after": "ABCD" }, "");
const samples = Array.from({ length: 50 }, () => backoffDelay(err, 0));
for (const s of samples) {
expect(s).toBeGreaterThanOrEqual(0);
expect(s).toBeLessThan(1_000);
}
});
it("exponential base scales 1s/2s/4s with full jitter, capped at 60s", () => {
const err = new HttpError(500, {}, "");
for (let attempt = 0; attempt < 4; attempt++) {
const cap = Math.min(60_000, 1_000 * Math.pow(2, attempt));
const samples = Array.from({ length: 30 }, () => backoffDelay(err, attempt));
for (const s of samples) {
expect(s).toBeGreaterThanOrEqual(0);
expect(s).toBeLessThan(cap || 1);
}
}
});
it("non-HttpError uses exponential", () => {
const samples = Array.from({ length: 30 }, () => backoffDelay(new Error("oops"), 1));
for (const s of samples) {
expect(s).toBeGreaterThanOrEqual(0);
expect(s).toBeLessThan(2_000);
}
});
});
describe("withRetry", () => {
it("returns the result on first success without retry", async () => {
const fn = vi.fn(async () => "ok");
const result = await withRetry(fn, { maxAttempts: 3 });
expect(result).toBe("ok");
expect(fn).toHaveBeenCalledTimes(1);
});
it("retries on retriable error and succeeds (429 -> 429 -> 200)", async () => {
let attempts = 0;
const fn = vi.fn(async () => {
attempts += 1;
if (attempts < 3) throw new HttpError(429, { "retry-after": "0" }, "");
return "ok";
});
const result = await withRetry(fn, { maxAttempts: 3 });
expect(result).toBe("ok");
expect(fn).toHaveBeenCalledTimes(3);
});
it("propagates non-retriable error immediately (no further attempts)", async () => {
const fn = vi.fn(async () => { throw new HttpError(401, {}, ""); });
await expect(withRetry(fn, { maxAttempts: 3 })).rejects.toMatchObject({ status: 401 });
expect(fn).toHaveBeenCalledTimes(1);
});
it("propagates the last error after maxAttempts", async () => {
// Retry-After: 0 keeps the inter-attempt sleep at 0ms so the test is deterministic.
const fn = vi.fn(async () => { throw new HttpError(503, { "retry-after": "0" }, ""); });
await expect(withRetry(fn, { maxAttempts: 3 })).rejects.toMatchObject({ status: 503 });
expect(fn).toHaveBeenCalledTimes(3);
});
it("invokes onRetry with attempt number, waitMs, and reason before each retry", async () => {
let attempts = 0;
const fn = async (): Promise<string> => {
attempts += 1;
if (attempts < 3) throw new HttpError(429, { "retry-after": "0" }, "");
return "ok";
};
const onRetry = vi.fn();
await withRetry(fn, { maxAttempts: 3, onRetry });
expect(onRetry).toHaveBeenCalledTimes(2);
expect(onRetry.mock.calls[0][0]).toMatchObject({ attempt: 2, reason: "HTTP 429" });
expect(onRetry.mock.calls[1][0]).toMatchObject({ attempt: 3, reason: "HTTP 429" });
});
it("respects canStillRetry returning false (skips retry)", async () => {
let started = false;
const fn = vi.fn(async () => {
started = true;
throw new HttpError(503, {}, "");
});
await expect(
withRetry(fn, { maxAttempts: 3, canStillRetry: () => !started }),
).rejects.toMatchObject({ status: 503 });
expect(fn).toHaveBeenCalledTimes(1);
});
it("respects an already-aborted signal and throws AbortError immediately", async () => {
const ctrl = new AbortController();
ctrl.abort();
const fn = vi.fn(async () => "ok");
await expect(
withRetry(fn, { maxAttempts: 3, signal: ctrl.signal }),
).rejects.toMatchObject({ name: "AbortError" });
expect(fn).not.toHaveBeenCalled();
});
it("aborts mid-sleep promptly and throws AbortError", async () => {
const ctrl = new AbortController();
const fn = vi.fn(async () => { throw new HttpError(503, { "retry-after": "60" }, ""); });
setTimeout(() => ctrl.abort(), 10);
const start = Date.now();
await expect(
withRetry(fn, { maxAttempts: 3, signal: ctrl.signal }),
).rejects.toMatchObject({ name: "AbortError" });
expect(Date.now() - start).toBeLessThan(500);
});
it("maxAttempts: 1 makes a single attempt and surfaces the error (no retry)", async () => {
const fn = vi.fn(async () => { throw new HttpError(503, {}, ""); });
await expect(withRetry(fn, { maxAttempts: 1 })).rejects.toMatchObject({ status: 503 });
expect(fn).toHaveBeenCalledTimes(1);
});
it("rejects immediately if signal aborts before sleepCancellable arms its timer", async () => {
// The fn aborts the signal as part of its rejection, so by the time the
// helper enters sleepCancellable the signal is already aborted. This
// exercises the pre-check inside the sleep promise.
const ctrl = new AbortController();
const fn = vi.fn(async () => {
ctrl.abort();
throw new HttpError(503, { "retry-after": "10" }, "");
});
await expect(
withRetry(fn, { maxAttempts: 3, signal: ctrl.signal }),
).rejects.toMatchObject({ name: "AbortError" });
expect(fn).toHaveBeenCalledTimes(1);
});
it("describes a non-Error throw as 'transport' in the onRetry reason", async () => {
let attempts = 0;
const fn = async (): Promise<string> => {
attempts += 1;
if (attempts === 1) throw "network down"; // non-Error throw
return "ok";
};
const onRetry = vi.fn();
const result = await withRetry(fn, { maxAttempts: 2, onRetry });
expect(result).toBe("ok");
expect(onRetry).toHaveBeenCalledTimes(1);
expect(onRetry.mock.calls[0][0].reason).toBe("transport");
});
});
describe("StreamResult shape", () => {
it("exposes headers as Record<string,string>", () => {
const r: import("../../src/providers/node-stream").StreamResult = {
status: 200,
fullText: "",
headers: { "content-type": "application/json" },
};
expect(r.headers["content-type"]).toBe("application/json");
});
});
-257
View File
@@ -1,257 +0,0 @@
/**
* PENNY - Per-Provider Streaming SSE Error Event Tests (#17)
*
* Verifies that each provider's streaming path detects mid-stream `error`
* events and rejects the completion with a descriptive error, instead of
* silently swallowing them and returning empty text.
*
* Mocks `streamRequest` so we can feed pre-canned SSE chunks directly to the
* provider's onChunk callback, avoiding the need to stand up a real test
* server with provider-specific URL routing.
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
vi.mock("../../src/providers/node-stream", async (importActual) => {
// Keep the real HttpError / withRetry / isRetriable / backoffDelay so the
// providers' refactored complete() wrappers function normally. Only swap
// the network primitives (streamRequest) and bypass the timeout helper.
const actual = await importActual<typeof import("../../src/providers/node-stream")>();
return {
...actual,
streamRequest: vi.fn(),
withTimeout: <T>(p: Promise<T>) => p,
};
});
import { streamRequest } from "../../src/providers/node-stream";
import { AnthropicProvider } from "../../src/providers/anthropic";
import { OpenAIProvider } from "../../src/providers/openai";
import { GoogleProvider } from "../../src/providers/google";
import { OllamaProvider } from "../../src/providers/ollama";
import type { HttpFn } from "../../src/providers/service";
const stubHttp: HttpFn = async () => ({ status: 200, headers: {}, text: "{}", json: {} });
const mockStream = vi.mocked(streamRequest);
beforeEach(() => {
mockStream.mockReset();
});
/** Convenience: program the mock to feed a sequence of SSE-formatted chunks. */
function programChunks(chunks: string[]): void {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
mockStream.mockImplementation(async (opts: any) => {
for (const chunk of chunks) {
opts.onChunk(chunk);
}
return { status: 200, fullText: chunks.join("") };
});
}
describe("Anthropic streaming — SSE error events", () => {
it("rejects with upstream message when an error event is received mid-stream", async () => {
programChunks([
'data: {"type":"message_start","message":{"usage":{"input_tokens":10}}}\n\n',
'data: {"type":"content_block_delta","delta":{"type":"text_delta","text":"Hello "}}\n\n',
'data: {"type":"error","error":{"type":"overloaded_error","message":"Server is currently overloaded"}}\n\n',
]);
const provider = new AnthropicProvider(stubHttp);
const tokens: string[] = [];
await expect(
provider.complete({ maxRetries: 0,
systemPrompt: "s",
userPrompt: "u",
model: "claude-sonnet-4-6",
maxTokens: 100,
apiKey: "mock-anthropic-test",
onToken: (t) => tokens.push(t),
}),
).rejects.toThrow(/Server is currently overloaded/);
});
it("does NOT silently include pre-error tokens in a successful result", async () => {
programChunks([
'data: {"type":"content_block_delta","delta":{"type":"text_delta","text":"Hello "}}\n\n',
'data: {"type":"content_block_delta","delta":{"type":"text_delta","text":"there"}}\n\n',
'data: {"type":"error","error":{"type":"api_error","message":"Internal failure"}}\n\n',
]);
const provider = new AnthropicProvider(stubHttp);
const result = await provider.complete({ maxRetries: 0,
systemPrompt: "s",
userPrompt: "u",
model: "claude-sonnet-4-6",
maxTokens: 100,
apiKey: "mock-anthropic-test",
onToken: () => { /* ignore */ },
}).catch((e) => e);
// Should be a thrown Error, not a CompletionResponse
expect(result).toBeInstanceOf(Error);
expect((result as Error).message).toMatch(/Internal failure/);
// The function rejected — caller has no way to access the partial text
expect("text" in (result as object)).toBe(false);
});
it("includes the error type in the thrown message", async () => {
programChunks([
'data: {"type":"error","error":{"type":"overloaded_error","message":"Overloaded"}}\n\n',
]);
const provider = new AnthropicProvider(stubHttp);
await expect(
provider.complete({ maxRetries: 0,
systemPrompt: "s",
userPrompt: "u",
model: "claude-sonnet-4-6",
maxTokens: 100,
apiKey: "mock-anthropic-test",
onToken: () => { /* required to take streaming path */ },
}),
).rejects.toThrow(/overloaded_error/);
});
});
describe("OpenAI streaming — SSE error events", () => {
it("rejects when an error chunk is received mid-stream", async () => {
programChunks([
'data: {"choices":[{"delta":{"content":"Hello "}}]}\n\n',
'data: {"error":{"message":"Rate limit exceeded","type":"rate_limit_exceeded"}}\n\n',
]);
const provider = new OpenAIProvider(stubHttp);
await expect(
provider.complete({ maxRetries: 0,
systemPrompt: "s",
userPrompt: "u",
model: "gpt-4.1",
maxTokens: 100,
apiKey: "mock-openai-test",
onToken: () => { /* ignore */ },
}),
).rejects.toThrow(/Rate limit exceeded/);
});
it("does NOT silently include pre-error tokens", async () => {
programChunks([
'data: {"choices":[{"delta":{"content":"partial"}}]}\n\n',
'data: {"error":{"message":"Service unavailable"}}\n\n',
]);
const provider = new OpenAIProvider(stubHttp);
const result = await provider.complete({ maxRetries: 0,
systemPrompt: "s",
userPrompt: "u",
model: "gpt-4.1",
maxTokens: 100,
apiKey: "mock-openai-test",
onToken: () => { /* ignore */ },
}).catch((e) => e);
expect(result).toBeInstanceOf(Error);
expect((result as Error).message).toMatch(/Service unavailable/);
});
});
describe("Google streaming — SSE error events", () => {
it("rejects when an error chunk is received mid-stream", async () => {
programChunks([
'data: {"candidates":[{"content":{"parts":[{"text":"Some "}]}}]}\n\n',
'data: {"error":{"code":429,"message":"Resource exhausted","status":"RESOURCE_EXHAUSTED"}}\n\n',
]);
const provider = new GoogleProvider(stubHttp);
await expect(
provider.complete({ maxRetries: 0,
systemPrompt: "s",
userPrompt: "u",
model: "gemini-2.5-flash",
maxTokens: 100,
apiKey: "mock-google-test",
onToken: () => { /* ignore */ },
}),
).rejects.toThrow(/Resource exhausted/);
});
it("does NOT silently include pre-error tokens", async () => {
programChunks([
'data: {"candidates":[{"content":{"parts":[{"text":"begin"}]}}]}\n\n',
'data: {"error":{"message":"Filtered for safety"}}\n\n',
]);
const provider = new GoogleProvider(stubHttp);
const result = await provider.complete({ maxRetries: 0,
systemPrompt: "s",
userPrompt: "u",
model: "gemini-2.5-flash",
maxTokens: 100,
apiKey: "mock-google-test",
onToken: () => { /* ignore */ },
}).catch((e) => e);
expect(result).toBeInstanceOf(Error);
expect((result as Error).message).toMatch(/Filtered for safety/);
});
});
describe("Ollama streaming — SSE error events", () => {
// Ollama's /v1/chat/completions endpoint is OpenAI-compatible; same shape.
it("rejects when an error chunk is received mid-stream", async () => {
programChunks([
'data: {"choices":[{"delta":{"content":"hi"}}]}\n\n',
'data: {"error":{"message":"Model not found"}}\n\n',
]);
const provider = new OllamaProvider(stubHttp);
await expect(
provider.complete({ maxRetries: 0,
systemPrompt: "s",
userPrompt: "u",
model: "llama3.2",
maxTokens: 100,
endpoint: "http://localhost:11434",
onToken: () => { /* ignore */ },
}),
).rejects.toThrow(/Model not found/);
});
it("rejects on bare-string Ollama error shape", async () => {
// Ollama also sometimes emits `{"error": "..."}` (string, not object)
programChunks([
'data: {"error":"context deadline exceeded"}\n\n',
]);
const provider = new OllamaProvider(stubHttp);
await expect(
provider.complete({ maxRetries: 0,
systemPrompt: "s",
userPrompt: "u",
model: "llama3.2",
maxTokens: 100,
endpoint: "http://localhost:11434",
onToken: () => { /* ignore */ },
}),
).rejects.toThrow(/context deadline exceeded/);
});
});
describe("Streaming error events — common contract", () => {
it("error message includes the provider name for triage", async () => {
programChunks([
'data: {"type":"error","error":{"type":"x","message":"y"}}\n\n',
]);
const provider = new AnthropicProvider(stubHttp);
const err = await provider.complete({ maxRetries: 0,
systemPrompt: "s",
userPrompt: "u",
model: "claude-sonnet-4-6",
maxTokens: 100,
apiKey: "mock-anthropic-test",
onToken: () => { /* required to take streaming path */ },
}).catch((e) => e);
expect((err as Error).message.toLowerCase()).toContain("anthropic");
});
});
-301
View File
@@ -1,301 +0,0 @@
import { describe, it, expect, beforeAll, afterAll } from "vitest";
import { createServer, Server } from "http";
import { AddressInfo } from "net";
import { streamRequest, withTimeout } from "../../src/providers/node-stream";
/**
* These tests spin up a tiny real HTTP server on an ephemeral port and exercise
* streamRequest() against it. Using a real server gives us genuine chunked
* delivery, abort behavior, and error-status coverage without mocking Node's
* http module.
*/
describe("streamRequest", () => {
let server: Server;
let baseUrl: string;
// Each test handler is a fresh function assigned per-test
let currentHandler: (req: import("http").IncomingMessage, res: import("http").ServerResponse) => void;
beforeAll(async () => {
server = createServer((req, res) => currentHandler(req, res));
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", () => resolve()));
const addr = server.address() as AddressInfo;
baseUrl = `http://127.0.0.1:${addr.port}`;
});
afterAll(async () => {
await new Promise<void>((resolve) => server.close(() => resolve()));
});
it("emits chunks as they arrive and resolves with status + fullText", async () => {
currentHandler = (_req, res) => {
res.writeHead(200, { "content-type": "text/plain" });
res.write("chunk-1-");
// Delay the second chunk so we can prove real chunked delivery
setTimeout(() => {
res.write("chunk-2");
res.end();
}, 20);
};
const received: string[] = [];
const result = await streamRequest({
url: `${baseUrl}/stream`,
method: "GET",
headers: {},
onChunk: (text) => received.push(text),
});
expect(result.status).toBe(200);
expect(result.fullText).toBe("chunk-1-chunk-2");
// Both chunks should have been delivered as separate onChunk calls
expect(received.length).toBeGreaterThanOrEqual(2);
expect(received.join("")).toBe("chunk-1-chunk-2");
});
it("POSTs a body and forwards headers", async () => {
let seenBody = "";
let seenAuth = "";
currentHandler = (req, res) => {
seenAuth = req.headers["authorization"] ?? "";
const bufs: Buffer[] = [];
req.on("data", (b: Buffer) => bufs.push(b));
req.on("end", () => {
seenBody = Buffer.concat(bufs).toString();
res.writeHead(200);
res.end("ok");
});
};
const result = await streamRequest({
url: `${baseUrl}/post`,
method: "POST",
headers: { authorization: "Bearer xyz", "content-type": "application/json" },
body: JSON.stringify({ hello: "world" }),
onChunk: () => { /* ignore */ },
});
expect(result.status).toBe(200);
expect(seenBody).toBe('{"hello":"world"}');
expect(seenAuth).toBe("Bearer xyz");
});
it("aborts mid-stream when the AbortSignal fires", async () => {
let serverSawAbort = false;
currentHandler = (req, res) => {
res.writeHead(200);
res.write("part-1");
req.on("close", () => {
// Node fires this when the client drops the connection
if (!res.writableEnded) serverSawAbort = true;
});
// Never end the response -- we're testing client-side abort
};
const controller = new AbortController();
const received: string[] = [];
const promise = streamRequest({
url: `${baseUrl}/slow`,
method: "GET",
headers: {},
signal: controller.signal,
onChunk: (text) => {
received.push(text);
// Abort after first chunk arrives
controller.abort();
},
});
await expect(promise).rejects.toMatchObject({ name: "AbortError" });
expect(received[0]).toBe("part-1");
// Give the server event loop a tick to process the socket close
await new Promise((r) => setTimeout(r, 20));
expect(serverSawAbort).toBe(true);
});
it("rejects immediately if signal is already aborted", async () => {
const controller = new AbortController();
controller.abort();
currentHandler = (_req, res) => {
res.writeHead(200);
res.end("should not be reached");
};
await expect(
streamRequest({
url: `${baseUrl}/x`,
method: "GET",
headers: {},
signal: controller.signal,
onChunk: () => { /* ignore */ },
}),
).rejects.toMatchObject({ name: "AbortError" });
});
it("captures 4xx responses with full body text (not thrown)", async () => {
currentHandler = (_req, res) => {
res.writeHead(400, { "content-type": "application/json" });
res.end(JSON.stringify({ error: { message: "bad request detail" } }));
};
const result = await streamRequest({
url: `${baseUrl}/bad`,
method: "POST",
headers: {},
body: "{}",
onChunk: () => { /* ignore */ },
});
expect(result.status).toBe(400);
expect(result.fullText).toContain("bad request detail");
});
it("captures 5xx responses with full body text", async () => {
currentHandler = (_req, res) => {
res.writeHead(503);
res.end("server overloaded");
};
const result = await streamRequest({
url: `${baseUrl}/oops`,
method: "GET",
headers: {},
onChunk: () => { /* ignore */ },
});
expect(result.status).toBe(503);
expect(result.fullText).toBe("server overloaded");
});
describe("timeout", () => {
it("rejects with TimeoutError when the server never responds", async () => {
// Handler accepts the connection but never writes a response. Without a
// timeout, this hangs forever -- the bug we're fixing in #18.
currentHandler = (_req, _res) => {
// Intentionally do nothing -- never send headers, never end the response.
};
const start = Date.now();
await expect(
streamRequest({
url: `${baseUrl}/never-responds`,
method: "GET",
headers: {},
timeoutMs: 150,
onChunk: () => { /* never called */ },
}),
).rejects.toMatchObject({ name: "TimeoutError" });
// Should fire near our 150ms threshold, not run to completion or vitest's 5s default.
const elapsed = Date.now() - start;
expect(elapsed).toBeGreaterThanOrEqual(140);
expect(elapsed).toBeLessThan(2000);
});
it("does not fire when the server responds within the budget", async () => {
currentHandler = (_req, res) => {
res.writeHead(200);
res.end("ok");
};
const result = await streamRequest({
url: `${baseUrl}/fast`,
method: "GET",
headers: {},
timeoutMs: 1000,
onChunk: () => { /* ignore */ },
});
expect(result.status).toBe(200);
expect(result.fullText).toBe("ok");
});
it("timeout error is distinguishable from AbortError", async () => {
currentHandler = (_req, _res) => {
// Never respond
};
const promise = streamRequest({
url: `${baseUrl}/timeout-not-abort`,
method: "GET",
headers: {},
timeoutMs: 100,
onChunk: () => { /* ignore */ },
});
const err = await promise.catch((e) => e);
expect(err).toBeInstanceOf(Error);
expect(err.name).toBe("TimeoutError");
expect(err.name).not.toBe("AbortError");
expect(err.message).toMatch(/timed? ?out/i);
});
});
it("does not let a throwing onChunk consumer kill the stream", async () => {
currentHandler = (_req, res) => {
res.writeHead(200);
res.write("a");
setTimeout(() => { res.write("b"); res.end(); }, 10);
};
const received: string[] = [];
const result = await streamRequest({
url: `${baseUrl}/throw`,
method: "GET",
headers: {},
onChunk: (text) => {
received.push(text);
if (text === "a") throw new Error("consumer failed");
},
});
expect(result.status).toBe(200);
expect(result.fullText).toBe("ab");
expect(received).toContain("a");
expect(received).toContain("b");
});
});
describe("withTimeout", () => {
it("resolves with the original value when the promise completes within budget", async () => {
const result = await withTimeout(Promise.resolve("ok"), 1000);
expect(result).toBe("ok");
});
it("rejects with TimeoutError when the promise takes longer than the budget", async () => {
const slow = new Promise((resolve) => setTimeout(() => resolve("late"), 200));
const start = Date.now();
await expect(withTimeout(slow, 50)).rejects.toMatchObject({
name: "TimeoutError",
});
const elapsed = Date.now() - start;
expect(elapsed).toBeGreaterThanOrEqual(40);
expect(elapsed).toBeLessThan(150);
});
it("propagates the timeoutMs in the error message", async () => {
const slow = new Promise(() => { /* never resolves */ });
const err = await withTimeout(slow, 75).catch((e) => e);
expect(err.message).toMatch(/75/);
expect(err.message).toMatch(/timed? ?out/i);
});
it("returns the promise unchanged when timeoutMs is undefined", async () => {
const slow = new Promise((resolve) => setTimeout(() => resolve("done"), 50));
const result = await withTimeout(slow, undefined);
expect(result).toBe("done");
});
it("returns the promise unchanged when timeoutMs is zero", async () => {
const slow = new Promise((resolve) => setTimeout(() => resolve("done"), 50));
const result = await withTimeout(slow, 0);
expect(result).toBe("done");
});
it("propagates rejections from the wrapped promise", async () => {
const failing = Promise.reject(new Error("upstream failure"));
await expect(withTimeout(failing, 1000)).rejects.toThrow("upstream failure");
});
});
-37
View File
@@ -1,37 +0,0 @@
import { describe, it, expect } from "vitest";
import { QUOTES, pickRandomQuote, quoteAt } from "../src/quotes";
describe("quotes", () => {
it("exposes a non-empty pool", () => {
expect(QUOTES.length).toBeGreaterThan(0);
for (const q of QUOTES) {
expect(typeof q.text).toBe("string");
expect(q.text.length).toBeGreaterThan(0);
expect(typeof q.author).toBe("string");
expect(q.author.length).toBeGreaterThan(0);
}
});
describe("pickRandomQuote", () => {
it("returns a quote from the pool", () => {
for (let i = 0; i < 20; i++) {
const q = pickRandomQuote();
expect(QUOTES).toContainEqual(q);
}
});
});
describe("quoteAt", () => {
it("returns the quote at the given index, modulo pool length", () => {
expect(quoteAt(0)).toEqual(QUOTES[0]);
expect(quoteAt(QUOTES.length)).toEqual(QUOTES[0]);
expect(quoteAt(QUOTES.length + 3)).toEqual(QUOTES[3]);
});
it("handles negative indices correctly (wraps positively)", () => {
expect(quoteAt(-1)).toEqual(QUOTES[QUOTES.length - 1]);
expect(quoteAt(-QUOTES.length)).toEqual(QUOTES[0]);
expect(quoteAt(-QUOTES.length - 1)).toEqual(QUOTES[QUOTES.length - 1]);
});
});
});
-409
View File
@@ -1,409 +0,0 @@
/**
* PENNY - Settings Validation Tests
*
* Tests for route config construction, pipeline settings consumption,
* and pure validation functions that underpin the settings UI.
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
import { buildRouteConfig } from "../src/pipeline";
import type { PipelineInput } from "../src/pipeline";
import { runPipeline } from "../src/pipeline";
import { resolveModel } from "../src/providers/router";
import { assembleContext } from "../src/context";
import type { ContextFiles } from "../src/context";
import { countProseWords } from "../src/frontmatter";
import { DEFAULT_SETTINGS, DEFAULT_SYSTEM_PROMPT } from "../src/types";
import type { PennySettings } from "../src/types";
import type { CompletionRequest, CompletionResponse } from "../src/providers/service";
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function makeSettings(overrides: Partial<PennySettings> = {}): PennySettings {
return { ...DEFAULT_SETTINGS, ...overrides };
}
function createMockProvider(responses: string[] = ["Revised text."]) {
let callIndex = 0;
const calls: CompletionRequest[] = [];
return {
provider: {
async complete(request: CompletionRequest): Promise<CompletionResponse> {
calls.push(request);
const text = responses[callIndex] ?? `[mock response ${callIndex}]`;
callIndex++;
return {
text,
model: request.model,
provider: "mock",
usage: { inputTokens: 100, outputTokens: 50 },
};
},
},
getCalls: () => calls,
};
}
function makeContextFiles(chapter: string): ContextFiles {
return {
chapter,
voiceTests: "",
styleGuide: "",
outline: "",
characters: [],
wiki: [],
seriesBible: "",
themes: "",
};
}
const SIMPLE_CHAPTER = `---
type: chapter
book: 1
chapter: 1
---
# Chapter 1
<!-- Prose begins below -->
She walked in.
%% REWRITE: Add sensory detail %%
`;
// ---------------------------------------------------------------------------
// Route config tests
// ---------------------------------------------------------------------------
describe("buildRouteConfig", () => {
it("with useSameModelForAll: true returns all three tiers as routeStandard", () => {
const settings = makeSettings({
useSameModelForAll: true,
routeLight: { provider: "ollama", model: "llama3.2" },
routeStandard: { provider: "anthropic", model: "claude-sonnet-4-6" },
routeHeavy: { provider: "anthropic", model: "claude-opus-4-6" },
});
const config = buildRouteConfig(settings);
expect(config.light).toEqual({ provider: "anthropic", model: "claude-sonnet-4-6" });
expect(config.standard).toEqual({ provider: "anthropic", model: "claude-sonnet-4-6" });
expect(config.heavy).toEqual({ provider: "anthropic", model: "claude-sonnet-4-6" });
});
it("with useSameModelForAll: false returns distinct routes per tier", () => {
const settings = makeSettings({
useSameModelForAll: false,
routeLight: { provider: "ollama", model: "llama3.2" },
routeStandard: { provider: "anthropic", model: "claude-sonnet-4-6" },
routeHeavy: { provider: "anthropic", model: "claude-opus-4-6" },
});
const config = buildRouteConfig(settings);
expect(config.light).toEqual({ provider: "ollama", model: "llama3.2" });
expect(config.standard).toEqual({ provider: "anthropic", model: "claude-sonnet-4-6" });
expect(config.heavy).toEqual({ provider: "anthropic", model: "claude-opus-4-6" });
});
it("with auto-latest resolves models correctly per tier", () => {
const settings = makeSettings({
useSameModelForAll: false,
routeLight: { provider: "anthropic", model: "auto-latest" },
routeStandard: { provider: "anthropic", model: "auto-latest" },
routeHeavy: { provider: "anthropic", model: "auto-latest" },
});
const config = buildRouteConfig(settings);
// buildRouteConfig returns the raw config; resolveModel is called
// downstream by getRoute. Verify the raw values pass through.
expect(config.light.model).toBe("auto-latest");
expect(config.standard.model).toBe("auto-latest");
expect(config.heavy.model).toBe("auto-latest");
// Verify that resolveModel correctly maps auto-latest per tier
expect(resolveModel(config.light.model, "light")).toBe("claude-haiku-4-5");
expect(resolveModel(config.standard.model, "standard")).toBe("claude-sonnet-4-6");
expect(resolveModel(config.heavy.model, "heavy")).toBe("claude-opus-4-6");
});
});
// ---------------------------------------------------------------------------
// Pipeline settings consumption tests
// ---------------------------------------------------------------------------
describe("pipeline settings consumption", () => {
it("maxTokens value from settings is passed to CompletionRequest", async () => {
const mock = createMockProvider(["Revised passage."]);
const settings = makeSettings({ maxTokens: 8000 });
const input: PipelineInput = {
content: SIMPLE_CHAPTER,
versionContent: "0",
stateContent: "",
contextFiles: makeContextFiles(SIMPLE_CHAPTER),
settings,
chapterId: "ch-01",
bookId: "book-1",
getProvider: () => mock.provider,
};
await runPipeline(input);
const calls = mock.getCalls();
expect(calls).toHaveLength(1);
expect(calls[0].maxTokens).toBe(8000);
});
it("contextBudget value limits context assembly", () => {
// Create a very small budget that can barely fit the chapter
const chapter = "A".repeat(500); // ~500 words worth
const files: ContextFiles = {
chapter,
voiceTests: "B".repeat(1000),
styleGuide: "C".repeat(1000),
outline: "",
characters: [],
wiki: [],
};
const annotation = {
tag: "REWRITE" as const,
instruction: "fix it",
originalText: "original",
lineStart: 1,
lineEnd: 2,
scope: "paragraph" as const,
actionable: true,
hash: "abc123",
};
// With a very tight budget, lower-priority items should be excluded
const smallBudgetSettings = makeSettings({ contextBudget: 100 });
const ctx = assembleContext(files, annotation, smallBudgetSettings);
// Chapter is always included (priority 1), but total should be constrained
expect(ctx.chapter).toBe(chapter);
expect(ctx.totalTokenEstimate).toBeGreaterThan(0);
// With a huge budget, everything should fit
const largeBudgetSettings = makeSettings({ contextBudget: 1000000 });
const ctxLarge = assembleContext(files, annotation, largeBudgetSettings);
expect(ctxLarge.voiceTests).toBe(files.voiceTests);
expect(ctxLarge.styleGuide).toBe(files.styleGuide);
});
it("customVoiceRules appears in assembled context", () => {
const files: ContextFiles = {
chapter: "Some chapter text.",
voiceTests: "",
styleGuide: "",
outline: "",
characters: [],
wiki: [],
};
const annotation = {
tag: "REWRITE" as const,
instruction: "fix it",
originalText: "original",
lineStart: 1,
lineEnd: 2,
scope: "paragraph" as const,
actionable: true,
hash: "abc123",
};
const settings = makeSettings({
customVoiceRules: "No internal monologue. Ever.",
});
const ctx = assembleContext(files, annotation, settings);
expect(ctx.voiceRules).toBe("No internal monologue. Ever.");
});
it("proseMarker affects word count calculation (with marker vs without)", () => {
const contentWithMarker = `---
type: chapter
---
# Chapter 1
## Outline notes that should not be counted
<!-- Prose begins below -->
She walked into the room and sat down.
`;
const contentWithoutMarker = `She walked into the room and sat down.`;
// With marker: only counts words after the marker
const countWithMarker = countProseWords(contentWithMarker, "<!-- Prose begins below -->");
// Without marker (empty string): counts all words
const countWithoutMarker = countProseWords(contentWithMarker, "");
expect(countWithMarker).toBeLessThan(countWithoutMarker);
// The prose section has 8 words
expect(countWithMarker).toBe(8);
});
it("logLevel controls console output verbosity", async () => {
const consoleSpy = vi.spyOn(console, "log").mockImplementation(() => {});
// logLevel is a settings flag. The pipeline itself does not
// directly call console.log (that happens in the command layer), but
// the setting must be propagated. Verify the setting value is accessible.
const settings = makeSettings({ logLevel: "debug" });
expect(settings.logLevel).toBe("debug");
const settingsOff = makeSettings({ logLevel: "off" });
expect(settingsOff.logLevel).toBe("off");
const settingsInfo = makeSettings({ logLevel: "info" });
expect(settingsInfo.logLevel).toBe("info");
consoleSpy.mockRestore();
});
});
// ---------------------------------------------------------------------------
// Validation tests (pure functions)
// ---------------------------------------------------------------------------
describe("resolveModel", () => {
it('resolveModel("auto-latest", "light") returns "claude-haiku-4-5"', () => {
expect(resolveModel("auto-latest", "light")).toBe("claude-haiku-4-5");
});
it('resolveModel("auto-latest", "standard") returns "claude-sonnet-4-6"', () => {
expect(resolveModel("auto-latest", "standard")).toBe("claude-sonnet-4-6");
});
it('resolveModel("auto-latest", "heavy") returns "claude-opus-4-6"', () => {
expect(resolveModel("auto-latest", "heavy")).toBe("claude-opus-4-6");
});
it('resolveModel("specific-model", "light") returns "specific-model" unchanged', () => {
expect(resolveModel("specific-model", "light")).toBe("specific-model");
});
});
describe("commitMessageFormat template substitution", () => {
it("replaces {chapter}, {version}, {tags} placeholders", () => {
const fmt = "docs({chapter}): PENNY v{version} - {tags}";
const result = fmt
.replace(/\{chapter\}/g, "ch-05")
.replace(/\{version\}/g, "3")
.replace(/\{tags\}/g, "REWRITE, EXPAND");
expect(result).toBe("docs(ch-05): PENNY v3 - REWRITE, EXPAND");
});
it("empty format string falls through to default", () => {
const fmt = "";
const chapterChanges = ["ch-01", "ch-02"];
// Simulate the command layer fallback logic
let message: string;
if (fmt) {
message = fmt
.replace(/\{chapter\}/g, "ch-01")
.replace(/\{version\}/g, "1")
.replace(/\{tags\}/g, "REWRITE");
} else if (chapterChanges.length > 0) {
message = `docs: revise ${chapterChanges.join(", ")}`;
} else {
message = "docs: PENNY writing session";
}
expect(message).toBe("docs: revise ch-01, ch-02");
});
});
describe("countProseWords", () => {
it("with proseMarker counts only after marker", () => {
const content = `# Heading
Some outline text here that should not count.
<!-- Prose begins below -->
The actual prose starts here with seven words.
`;
const count = countProseWords(content, "<!-- Prose begins below -->");
// "The actual prose starts here with seven words." = 8 words
expect(count).toBe(8);
});
it("without proseMarker counts entire content", () => {
const content = `# Heading
Some text here.
More text here.
`;
const count = countProseWords(content, "");
// All words in the content are counted
expect(count).toBeGreaterThan(0);
// "Heading Some text here. More text here." = 7 words
expect(count).toBe(7);
});
});
// ---------------------------------------------------------------------------
// Integration-level tests for non-default settings
// ---------------------------------------------------------------------------
describe("integration: non-default maxTokens", () => {
it("non-default maxTokens (8000) reaches the provider", async () => {
const mock = createMockProvider(["Revised."]);
const input: PipelineInput = {
content: SIMPLE_CHAPTER,
versionContent: "0",
stateContent: "",
contextFiles: makeContextFiles(SIMPLE_CHAPTER),
settings: makeSettings({ maxTokens: 8000 }),
chapterId: "ch-01",
bookId: "book-1",
getProvider: () => mock.provider,
};
const result = await runPipeline(input);
expect(result).not.toBeNull();
const calls = mock.getCalls();
expect(calls).toHaveLength(1);
expect(calls[0].maxTokens).toBe(8000);
});
it("empty customVoiceRules still produces a working prompt", async () => {
const mock = createMockProvider(["Revised."]);
const input: PipelineInput = {
content: SIMPLE_CHAPTER,
versionContent: "0",
stateContent: "",
contextFiles: makeContextFiles(SIMPLE_CHAPTER),
settings: makeSettings({ customVoiceRules: "" }),
chapterId: "ch-01",
bookId: "book-1",
getProvider: () => mock.provider,
};
const result = await runPipeline(input);
expect(result).not.toBeNull();
const calls = mock.getCalls();
expect(calls).toHaveLength(1);
// The system prompt should still be valid (not crash or be empty)
expect(calls[0].systemPrompt.length).toBeGreaterThan(0);
// The voice_rules section should be absent or empty
expect(calls[0].systemPrompt).not.toContain("CRITICAL VOICE RULES");
});
});
-36
View File
@@ -34,42 +34,6 @@ describe("checkVoiceCompliance", () => {
expect(result.dialogueRatio).toBeGreaterThan(0);
});
it("counts British single-quoted dialogue (#5)", () => {
// 'Hello there' = 2 words of dialogue out of 5 total \u2192 ratio 0.4
const prose = "'Hello there,' she said.";
const result = checkVoiceCompliance(prose);
expect(result.dialogueRatio).toBeGreaterThan(0);
expect(result.dialogueRatio).toBeLessThan(1);
});
it("does not double-count apostrophes inside contractions (#5)", () => {
// The contraction "don't" must not register as opening a quote span.
const prose = "She said \"I don't know what you're doing.\" The day went on.";
const result = checkVoiceCompliance(prose);
// Dialogue: "I don't know what you're doing" = 6 words
// Total words: ~14
expect(result.dialogueRatio).toBeGreaterThan(0.3);
expect(result.dialogueRatio).toBeLessThan(0.6);
});
it("counts mixed quote styles in the same chapter (#5)", () => {
const prose = '"Hi," he said. \u2018Bye,\u2019 she replied. \u201CGood,\u201D he agreed.';
const result = checkVoiceCompliance(prose);
// Three single-word dialogue spans out of ~9 total \u2192 > 0.3
expect(result.dialogueRatio).toBeGreaterThan(0.3);
});
it("strips British single-quoted dialogue from narration analysis (#5)", () => {
// Without stripping, the long sentence below would NOT exceed 20 words
// because the dialogue would inflate the count. With proper stripping,
// the narration is short and below threshold.
const prose =
"'I think I shall walk to the very far end of the very long lane today.' She said it firmly.";
const result = checkVoiceCompliance(prose);
// Narration is short; should not flag a long sentence.
expect(result.longNarrationSentences).toHaveLength(0);
});
it("returns 0 for empty text", () => {
const result = checkVoiceCompliance("");
expect(result.dialogueRatio).toBe(0);
+2 -5
View File
@@ -6,14 +6,11 @@
"module": "ESNext",
"target": "ES2018",
"allowJs": true,
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true,
"noImplicitAny": true,
"moduleResolution": "node",
"importHelpers": true,
"isolatedModules": true,
"strictNullChecks": true,
"lib": ["DOM", "ES5", "ES6", "ES7"],
"outDir": "./dist",
"paths": {
-54
View File
@@ -4,59 +4,5 @@ export default defineConfig({
test: {
include: ["test/**/*.test.ts"],
globals: true,
coverage: {
provider: "v8",
reporter: ["text", "json-summary", "html"],
include: ["src/**/*.ts"],
// Shell modules and pure-type files are excluded from threshold gating.
// The pure-logic / Obsidian-shell import boundary in this codebase makes
// these inherently hard to test without Obsidian mocking infrastructure.
// See audit issues #7 (split commands.ts) and #39 (extract testable
// logic from commands.ts) for the path to bringing these into coverage.
exclude: [
"src/main.ts",
"src/commands.ts",
"src/settings.ts",
"src/setup-wizard.ts",
"src/*-modal.ts",
"src/statusbar.ts",
"src/types.ts",
"src/logger.ts",
],
thresholds: {
// Global floor — ratchet only up.
//
// After issue #17 (SSE error events): lines 90.93%, statements 89.77%,
// functions 96.38%, branches 77.54%.
// After issue #15 (retry/backoff) on top of #17: lines 91.60%,
// statements 90.51%, functions 97.43%, branches 79.08%.
lines: 91,
statements: 90,
functions: 97,
branches: 79,
// Per-file ratchets — each module locked at or just below its current
// coverage so it cannot regress. When tests rise, raise the lock here
// in the same PR. Modules at 100% are pinned at 100%.
"src/versioner.ts": { lines: 100, functions: 100 },
"src/migrate.ts": { lines: 100, functions: 100 },
"src/drafter.ts": { lines: 100, functions: 100 },
"src/progress-modal-state.ts": { lines: 100, functions: 100 },
"src/utils.ts": { lines: 100, functions: 100 },
"src/quotes.ts": { lines: 100, functions: 100 },
"src/providers/registry.ts": { lines: 100, functions: 100 },
"src/providers/router.ts": { lines: 100, functions: 100 },
"src/providers/index.ts": { lines: 100, functions: 100 },
"src/providers/service.ts": { lines: 100, functions: 100 },
"src/frontmatter.ts": { lines: 97, functions: 100 },
"src/pipeline.ts": { lines: 95, functions: 87 },
"src/providers/node-stream.ts": { lines: 96, functions: 100 },
"src/parser.ts": { lines: 88, functions: 100 },
"src/reviewer.ts": { lines: 85 },
"src/context.ts": { lines: 83, functions: 100 },
"src/assembler.ts": { lines: 81 },
"src/project-config.ts": { lines: 71 },
},
},
},
});