Compare commits
61
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2a34d74bea
|
||
|
|
324830218c | ||
|
|
5499a86936 | ||
|
|
823adf3c2a
|
||
|
|
2593993622 | ||
|
|
2eb7aad21f
|
||
|
|
a00cc64edc
|
||
|
|
ca65cba959
|
||
|
|
dffe635c55
|
||
|
|
c7bf2373c2
|
||
|
|
73c1a9e5fe
|
||
|
|
6d0e0cf33f | ||
|
|
012d73d795 | ||
|
|
8be93c9e3e | ||
|
|
28e701a450 | ||
|
|
ea3cbd2587 | ||
|
|
faf6cc8d9f | ||
|
|
022941bdfa | ||
|
|
d5d5e9e063 | ||
|
|
8a35989c7d | ||
|
|
33356b568b | ||
|
|
cf64834eaa | ||
|
|
46bf4cdd45 | ||
|
|
4bcf751887 | ||
|
|
c4244bd857 | ||
|
|
c255aaaedd | ||
|
|
910d55b77e | ||
|
|
4667014ac0 | ||
|
|
568c3f38db | ||
|
|
25de729874 | ||
|
|
7d2e1904fb | ||
|
|
7ab72f324b | ||
|
|
2d23f8949f | ||
|
|
eba9ddfb62 | ||
|
|
e46d3095b8 | ||
|
|
2df6b79b0b | ||
|
|
a8224ddb69 | ||
|
|
1793be1f1c | ||
|
|
082a8c517e | ||
|
|
6bd57df732 | ||
|
|
017e14b9f3 | ||
|
|
067148ded1 | ||
|
|
d0593eeaf4 | ||
|
|
0ae2a2db32 | ||
|
|
b98af6d79d | ||
|
|
1c5d57545a | ||
|
|
b20c674751 | ||
|
|
12c9dbbb4d | ||
|
|
2fa46ba5f9 | ||
|
|
8432ac1311 | ||
|
|
f1ce10f62e | ||
|
|
eff071d099 | ||
|
|
78ca1e004c | ||
|
|
1636d3d282 | ||
|
|
86651a92ce | ||
|
|
e98ccc72b4 | ||
|
|
05c0d92fb9 | ||
|
|
4dc6c32dfc | ||
|
|
a457944834 | ||
|
|
ef564eb101 | ||
|
|
583551dea7 |
No files matched your search
@@ -1,31 +0,0 @@
|
||||
# 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
|
||||
@@ -0,0 +1,13 @@
|
||||
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
|
||||
@@ -1,41 +0,0 @@
|
||||
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
|
||||
@@ -22,3 +22,6 @@ Thumbs.db
|
||||
|
||||
# Test
|
||||
coverage/
|
||||
|
||||
# AI tooling caches (session/transcript data, not portable)
|
||||
.claude/
|
||||
@@ -0,0 +1,27 @@
|
||||
# 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/.+''',
|
||||
]
|
||||
@@ -0,0 +1,113 @@
|
||||
# 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.
|
||||
+42
-24
@@ -225,59 +225,77 @@ He mentioned the Tao of Programming, quoting the passage about the master progra
|
||||
|
||||
## Scope Rules
|
||||
|
||||
Where you place the annotation determines what passage PENNY applies it to.
|
||||
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.
|
||||
|
||||
### Inline (within a paragraph)
|
||||
### Single paragraph
|
||||
|
||||
The annotation applies to the sentence or clause immediately before it.
|
||||
Put the annotation on its own line after the paragraph you want to change.
|
||||
|
||||
```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 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. %%
|
||||
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 next morning, she biked to campus.
|
||||
```
|
||||
|
||||
PENNY revises the paragraph above the annotation. The paragraph below is untouched.
|
||||
|
||||
### After a heading (line after `#`)
|
||||
### Multiple paragraphs (block scope)
|
||||
|
||||
The annotation applies to the entire section up to the next heading of equal or higher level.
|
||||
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.
|
||||
|
||||
```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 arranged upon the workbenches. The fluorescent lighting cast a sterile glow across the room. She proceeded to her workstation and initiated the boot sequence.
|
||||
She entered the laboratory and observed the equipment...
|
||||
```
|
||||
|
||||
PENNY revises everything from "She entered..." to the next `##` heading.
|
||||
|
||||
### Multiple annotations on the same passage
|
||||
|
||||
**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).
|
||||
Each annotation is processed independently. If multiple annotations target the same passage, each generates a separate revision pass applied in sequence.
|
||||
|
||||
```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. %%
|
||||
```
|
||||
|
||||
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.
|
||||
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
@@ -0,0 +1,110 @@
|
||||
# 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.
|
||||
@@ -1,6 +1,8 @@
|
||||
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
|
||||
@@ -41,6 +43,16 @@ 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();
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
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"],
|
||||
}
|
||||
);
|
||||
Generated
+1375
-30
File diff suppressed because it is too large.
Load diff
+8
-1
@@ -5,10 +5,13 @@
|
||||
"main": "main.js",
|
||||
"scripts": {
|
||||
"dev": "node esbuild.config.mjs",
|
||||
"build": "tsc -noEmit -skipLibCheck && node esbuild.config.mjs production",
|
||||
"build": "tsc -noEmit && node esbuild.config.mjs production",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"lint": "tsc --noEmit",
|
||||
"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": [
|
||||
@@ -24,12 +27,16 @@
|
||||
"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"
|
||||
}
|
||||
}
|
||||
+28
-5
@@ -1,15 +1,30 @@
|
||||
/**
|
||||
* PENNY - Version Assembly
|
||||
*
|
||||
* Builds a new version of a chapter from processed revisions.
|
||||
* Works bottom-up to preserve line numbers during replacement.
|
||||
* 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.
|
||||
*
|
||||
* 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 is created per use. */
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
const ANNOTATION_PATTERN = /%%\s*([A-Z]+)\s*:\s*(.*?)\s*%%/;
|
||||
|
||||
function wordCount(text: string): number {
|
||||
@@ -22,15 +37,23 @@ function isPassthrough(tag: string): boolean {
|
||||
return (PASSTHROUGH_TAGS as readonly string[]).includes(tag);
|
||||
}
|
||||
|
||||
/** Info about an annotation that was skipped due to overlap. */
|
||||
/** Info about an annotation that was skipped because its range overlapped a larger annotation. */
|
||||
export interface SkippedOverlap {
|
||||
/** The tag of the skipped annotation. */
|
||||
tag: string;
|
||||
/** The starting line of the skipped annotation. */
|
||||
line: number;
|
||||
}
|
||||
|
||||
/** Result of assembleNewVersion, including assembled text and any skipped overlaps. */
|
||||
/**
|
||||
* Result of assembleNewVersion.
|
||||
* Contains the fully assembled chapter text and metadata about any
|
||||
* annotations that were dropped due to overlapping ranges.
|
||||
*/
|
||||
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[];
|
||||
}
|
||||
|
||||
|
||||
+558
-143
File diff suppressed because it is too large.
Load diff
+33
-8
@@ -1,17 +1,36 @@
|
||||
/**
|
||||
* PENNY - Context Assembly (pure logic)
|
||||
*
|
||||
* 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.
|
||||
* 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.
|
||||
*
|
||||
* Pure function -- no Obsidian API dependencies.
|
||||
*/
|
||||
|
||||
import type { AnnotatedSection, AssembledContext, PennySettings } from "./types";
|
||||
|
||||
/**
|
||||
* Files provided to the context assembler.
|
||||
* All fields are string contents (already read from the vault).
|
||||
* 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.
|
||||
*/
|
||||
export interface ContextFiles {
|
||||
chapter: string;
|
||||
@@ -42,8 +61,14 @@ 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, that section is included. If no match, the full file is returned
|
||||
* (up to a reasonable limit).
|
||||
* 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)
|
||||
*/
|
||||
export function selectVoiceTestSection(
|
||||
voiceTestsContent: string,
|
||||
@@ -112,7 +137,7 @@ export function selectVoiceTestSection(
|
||||
*/
|
||||
export function assembleContext(
|
||||
files: ContextFiles,
|
||||
annotation: AnnotatedSection,
|
||||
_annotation: AnnotatedSection,
|
||||
settings: PennySettings,
|
||||
tokenMultiplier = 1.33,
|
||||
): AssembledContext {
|
||||
|
||||
+23
-8
@@ -1,19 +1,32 @@
|
||||
/**
|
||||
* PENNY - Prompt Construction & Provider Dispatch
|
||||
*
|
||||
* 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).
|
||||
* 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).
|
||||
*/
|
||||
|
||||
import type { AssembledContext, AnnotatedSection } from "./types";
|
||||
import type { CompletionRequest, CompletionResponse } from "./providers/service";
|
||||
|
||||
/**
|
||||
* All placeholders that can appear in the system prompt template.
|
||||
* Each maps to either a field on AssembledContext or a field on
|
||||
* AnnotatedSection.
|
||||
* 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.
|
||||
*/
|
||||
const SECTION_PLACEHOLDERS: Record<string, { label: string; contextKey?: keyof AssembledContext }> = {
|
||||
"{voice_rules}": { label: "CRITICAL VOICE RULES", contextKey: "voiceRules" },
|
||||
@@ -22,6 +35,8 @@ 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" },
|
||||
};
|
||||
|
||||
@@ -115,7 +130,7 @@ export async function callProvider(
|
||||
return provider.complete(request);
|
||||
}
|
||||
|
||||
/** Escape special regex characters in a string. */
|
||||
/** Escape special regex characters in a string so it can be used in a RegExp constructor. */
|
||||
function escapeRegExp(str: string): string {
|
||||
return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
/**
|
||||
* 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();
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
}
|
||||
+68
-16
@@ -1,11 +1,20 @@
|
||||
/**
|
||||
* PENNY - YAML Frontmatter
|
||||
*
|
||||
* Parse and update chapter frontmatter.
|
||||
* Pure functions -- no Obsidian API dependencies.
|
||||
* 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.
|
||||
*
|
||||
* 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";
|
||||
@@ -19,6 +28,12 @@ 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;
|
||||
@@ -55,7 +70,8 @@ export function parseFrontmatter(content: string): {
|
||||
}
|
||||
|
||||
// Key: value pair
|
||||
const kvMatch = line.match(/^([a-zA-Z_][a-zA-Z0-9_]*)\s*:\s*(.*)/);
|
||||
// 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*(.*)/);
|
||||
if (!kvMatch) continue;
|
||||
|
||||
const key = kvMatch[1];
|
||||
@@ -96,6 +112,7 @@ 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("'"))) {
|
||||
@@ -122,7 +139,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;
|
||||
}
|
||||
|
||||
@@ -137,6 +154,12 @@ 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[] = [];
|
||||
@@ -190,6 +213,10 @@ 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,
|
||||
@@ -201,8 +228,14 @@ export function updateAgentFields(
|
||||
/**
|
||||
* Count prose words in the chapter body.
|
||||
*
|
||||
* Only counts text after the `proseMarker` comment. If the marker is not
|
||||
* found, counts all words in the 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
|
||||
*/
|
||||
export function countProseWords(content: string, proseMarker: string): number {
|
||||
let text = content;
|
||||
@@ -229,11 +262,17 @@ export function countProseWords(content: string, proseMarker: string): number {
|
||||
/**
|
||||
* Detect characters mentioned in the chapter.
|
||||
*
|
||||
* 1. Parse the `focus` frontmatter field (comma-separated names).
|
||||
* Two detection strategies:
|
||||
* 1. Parse the `focus` frontmatter field (comma/plus/ampersand-separated names).
|
||||
* 2. Scan dialogue for attribution patterns like `NAME said`, `said NAME`,
|
||||
* `NAME asked`, etc.
|
||||
*
|
||||
* Returns a deduplicated, lowercased array of character identifiers.
|
||||
* 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
|
||||
*/
|
||||
export function detectCharacters(content: string, focusField: string): string[] {
|
||||
const chars = new Set<string>();
|
||||
@@ -246,17 +285,30 @@ export function detectCharacters(content: string, focusField: string): string[]
|
||||
}
|
||||
}
|
||||
|
||||
// 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());
|
||||
// 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());
|
||||
}
|
||||
|
||||
// Reverse: `said Name`
|
||||
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());
|
||||
const reverseRe = new RegExp(`(?:${VERBS})\\s+(${NAME})\\b`, "gm");
|
||||
for (const match of content.matchAll(reverseRe)) {
|
||||
chars.add(match[1].toLowerCase());
|
||||
}
|
||||
|
||||
return Array.from(chars);
|
||||
|
||||
+40
-3
@@ -1,11 +1,17 @@
|
||||
/**
|
||||
* PENNY - Activity Logger
|
||||
*
|
||||
* 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.
|
||||
* 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.
|
||||
*/
|
||||
|
||||
import type { ActivityLogEntry } from "./types";
|
||||
import type { ActivityLogEntry, LogLevel } from "./types";
|
||||
|
||||
/**
|
||||
* Serialize an activity log entry to a single JSONL line (JSON + newline).
|
||||
@@ -34,6 +40,37 @@ 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.
|
||||
*
|
||||
|
||||
+131
-20
@@ -1,16 +1,27 @@
|
||||
/**
|
||||
* PENNY - Prose Engine for Narrative, Notes, and Yarns
|
||||
*
|
||||
* 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.
|
||||
* 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.
|
||||
*
|
||||
* @module main
|
||||
*/
|
||||
|
||||
import { Plugin, Notice, TFile, requestUrl } from "obsidian";
|
||||
import { Menu, Plugin, 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";
|
||||
@@ -48,6 +59,15 @@ 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;
|
||||
|
||||
@@ -84,14 +104,8 @@ export default class PennyPlugin extends Plugin {
|
||||
// Register all commands
|
||||
registerCommands(this);
|
||||
|
||||
// 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");
|
||||
});
|
||||
// Set up togglable UI elements
|
||||
this.setupUI();
|
||||
|
||||
// Update status bar when the active file changes
|
||||
this.registerEvent(
|
||||
@@ -107,16 +121,12 @@ 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 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
|
||||
);
|
||||
// First-run setup wizard
|
||||
if (!this.settings.setupComplete) {
|
||||
new SetupWizardModal(this).open();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -147,6 +157,9 @@ 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();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -195,6 +208,104 @@ 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.
|
||||
|
||||
+22
-4
@@ -1,7 +1,14 @@
|
||||
/**
|
||||
* PENNY - Chapter Migration (pure logic)
|
||||
*
|
||||
* Logic for converting flat chapter files to versioned folders.
|
||||
* 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.
|
||||
*
|
||||
* Pure functions -- no Obsidian API dependencies.
|
||||
*/
|
||||
|
||||
@@ -9,6 +16,7 @@ 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` */
|
||||
@@ -32,6 +40,9 @@ 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[] = [];
|
||||
@@ -74,21 +85,28 @@ export function planMigration(files: string[]): MigrationPlan[] {
|
||||
|
||||
/**
|
||||
* Generate the v1 content from the original file.
|
||||
* The content stays the same -- this is a straight copy.
|
||||
* 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
|
||||
*/
|
||||
export function generateV1Content(originalContent: string): string {
|
||||
return originalContent;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate the initial .version file content.
|
||||
* Generate the initial `.version` file content.
|
||||
*
|
||||
* @returns The string "1" (first version)
|
||||
*/
|
||||
export function generateVersionFile(): string {
|
||||
return "1";
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate the initial .state.json file content.
|
||||
* Generate the initial `.state.json` file content.
|
||||
*
|
||||
* @returns Pretty-printed JSON for a fresh VersionState (version 1, no processed annotations)
|
||||
*/
|
||||
export function generateStateJson(): string {
|
||||
const initialState: VersionState = {
|
||||
|
||||
+158
-24
@@ -1,7 +1,20 @@
|
||||
/**
|
||||
* PENNY - Annotation Parser
|
||||
*
|
||||
* Parses `%% TAG: instruction %%` annotations from markdown text.
|
||||
* 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
|
||||
*
|
||||
* Pure function -- no Obsidian API dependencies.
|
||||
*/
|
||||
|
||||
@@ -9,26 +22,47 @@ import {
|
||||
type AnnotatedSection,
|
||||
type AnnotationTag,
|
||||
ACTIONABLE_TAGS,
|
||||
PASSTHROUGH_TAGS,
|
||||
ALL_TAGS,
|
||||
} from "./types";
|
||||
|
||||
/** Non-global regex pattern for matching annotations. A new RegExp is created per use. */
|
||||
/** Single-line annotation: `%% TAG: instruction %%` on one line. Captures tag and instruction. */
|
||||
const ANNOTATION_PATTERN = /%%\s*([A-Z]+)\s*:\s*(.*?)\s*%%/;
|
||||
|
||||
/**
|
||||
* Strip all annotation markers from a string.
|
||||
* 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
|
||||
*/
|
||||
function stripAnnotations(text: string): string {
|
||||
return text.replace(new RegExp(ANNOTATION_PATTERN.source, "g"), "");
|
||||
// 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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a deterministic hash from the annotation's identity fields.
|
||||
* 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"`.
|
||||
*
|
||||
* 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)
|
||||
*/
|
||||
function hashAnnotation(tag: string, instruction: string, originalText: string): string {
|
||||
const input = `${tag}|${instruction}|${originalText}`;
|
||||
@@ -48,10 +82,12 @@ 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);
|
||||
}
|
||||
@@ -72,21 +108,39 @@ 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"; lineStart: number; lineEnd: number } {
|
||||
): { scope: "inline" | "paragraph" | "section" | "block"; 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 };
|
||||
}
|
||||
|
||||
@@ -98,10 +152,24 @@ 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) {
|
||||
@@ -109,30 +177,31 @@ 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])) {
|
||||
while (paraStart > 0 && !isBlank(lines[paraStart - 1]) && !isHeading(lines[paraStart - 1]) && !lines[paraStart - 1].trim().startsWith("%% REVISED(")) {
|
||||
paraStart--;
|
||||
}
|
||||
return { scope: "paragraph", lineStart: paraStart, lineEnd: annotationLineIndex };
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse all annotations from a markdown string.
|
||||
* Parse all annotations from a markdown chapter string.
|
||||
*
|
||||
* Returns an array of `AnnotatedSection` objects sorted by `lineStart`
|
||||
* ascending. Malformed annotations (unrecognised tags, missing instruction)
|
||||
* are silently skipped.
|
||||
* 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.
|
||||
*/
|
||||
export function parseAnnotations(content: string): AnnotatedSection[] {
|
||||
const lines = content.split("\n");
|
||||
@@ -140,10 +209,13 @@ export function parseAnnotations(content: string): AnnotatedSection[] {
|
||||
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const line = lines[i];
|
||||
// Create a fresh global regex for each line to avoid shared state issues
|
||||
|
||||
// Try single-line annotations first
|
||||
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();
|
||||
|
||||
@@ -187,6 +259,68 @@ 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).
|
||||
|
||||
+229
-12
@@ -1,10 +1,23 @@
|
||||
/**
|
||||
* 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,
|
||||
* runs the full annotation processing pipeline, and returns results that
|
||||
* the command layer writes back to the vault.
|
||||
* 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.
|
||||
*/
|
||||
|
||||
import type { AnnotatedSection, AnnotationChange, ReviewFlag, PennySettings } from "./types";
|
||||
@@ -19,13 +32,15 @@ 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 } from "./logger";
|
||||
import { createLogEntry, formatLogEntry, pennyLog } 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.
|
||||
* LLMService that the pipeline actually uses (complete() + optional
|
||||
* tokenMultiplier). This keeps the pipeline decoupled from the full
|
||||
* provider implementation.
|
||||
*/
|
||||
export interface PipelineProvider {
|
||||
complete(request: CompletionRequest): Promise<CompletionResponse>;
|
||||
@@ -33,16 +48,70 @@ 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 {
|
||||
@@ -70,7 +139,11 @@ export interface PipelineResult {
|
||||
|
||||
/**
|
||||
* Build the RouteConfig from plugin settings.
|
||||
* When useSameModelForAll is true, all tiers use the standard route.
|
||||
* 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
|
||||
*/
|
||||
export function buildRouteConfig(s: PennySettings): RouteConfig {
|
||||
if (s.useSameModelForAll) {
|
||||
@@ -91,14 +164,20 @@ export function buildRouteConfig(s: PennySettings): RouteConfig {
|
||||
* Run the core processing pipeline on chapter content.
|
||||
*
|
||||
* Pure logic except for LLM calls (injected via getProvider).
|
||||
* Returns null if there are no new annotations to process.
|
||||
* 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
|
||||
*/
|
||||
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
|
||||
const allAnnotations = parseAnnotations(content);
|
||||
// (a) Parse annotations (use pre-parsed if provided to avoid redundant work)
|
||||
const allAnnotations = input.preParsedAnnotations ?? parseAnnotations(content);
|
||||
|
||||
// (b) Read version and state
|
||||
const currentVersion = readVersion(input.versionContent);
|
||||
@@ -114,6 +193,13 @@ 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;
|
||||
|
||||
@@ -126,11 +212,41 @@ export async function runPipeline(input: PipelineInput): Promise<PipelineResult
|
||||
const flags: ReviewFlag[] = [];
|
||||
const processedTags: string[] = [];
|
||||
let hadErrors = false;
|
||||
let completedCount = 0;
|
||||
|
||||
for (const annotation of toProcess) {
|
||||
// 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];
|
||||
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}`;
|
||||
@@ -141,6 +257,15 @@ 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;
|
||||
}
|
||||
|
||||
@@ -167,7 +292,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
|
||||
// Call provider (with streaming token callback when progress is available)
|
||||
const response = await callProvider(provider, {
|
||||
systemPrompt: system,
|
||||
userPrompt: user,
|
||||
@@ -175,9 +300,36 @@ 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 });
|
||||
@@ -204,7 +356,30 @@ 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 });
|
||||
@@ -214,11 +389,48 @@ 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;
|
||||
|
||||
@@ -293,6 +505,11 @@ 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,
|
||||
|
||||
@@ -0,0 +1,180 @@
|
||||
/**
|
||||
* 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.";
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,333 @@
|
||||
/**
|
||||
* 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}`);
|
||||
}
|
||||
}
|
||||
+37
-9
@@ -3,15 +3,25 @@
|
||||
*
|
||||
* 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.
|
||||
* directory looking for the nearest PENNY.md (like how .gitignore
|
||||
* resolution works).
|
||||
*
|
||||
* Parsed key-value pairs from the `## Project` section override
|
||||
* global settings.
|
||||
* 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.
|
||||
*/
|
||||
|
||||
import type { PennySettings } from "./types";
|
||||
|
||||
/** Map of PENNY.md keys to PennySettings field names. */
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
const KEY_MAP: Record<string, keyof PennySettings> = {
|
||||
"drafts": "draftsFolder",
|
||||
"style-guide": "styleGuide",
|
||||
@@ -28,7 +38,8 @@ const KEY_MAP: Record<string, keyof PennySettings> = {
|
||||
|
||||
/**
|
||||
* Minimal vault interface so this module has no direct Obsidian dependency.
|
||||
* The real Vault satisfies this shape.
|
||||
* The real Obsidian Vault object satisfies this shape at runtime.
|
||||
* In tests, a simple mock can be substituted.
|
||||
*/
|
||||
export interface VaultReader {
|
||||
adapter: {
|
||||
@@ -39,7 +50,11 @@ export interface VaultReader {
|
||||
|
||||
/**
|
||||
* Walk up from a file's directory looking for a PENNY.md file.
|
||||
* Returns the parsed overrides or null if no PENNY.md is found.
|
||||
* 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
|
||||
*/
|
||||
export async function findProjectConfig(
|
||||
filePath: string,
|
||||
@@ -81,8 +96,16 @@ export async function findProjectConfig(
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse the `## Project` section from a PENNY.md file.
|
||||
* Extracts simple `key: value` pairs and maps them to PennySettings fields.
|
||||
* 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
|
||||
*/
|
||||
export function parseProjectConfig(content: string): Partial<PennySettings> {
|
||||
const overrides: Partial<PennySettings> = {};
|
||||
@@ -146,7 +169,12 @@ export function parseProjectConfig(content: string): Partial<PennySettings> {
|
||||
voiceLines.push(line);
|
||||
}
|
||||
|
||||
const voiceRulesText = voiceLines.join("\n").trim();
|
||||
// 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();
|
||||
if (voiceRulesText) {
|
||||
overrides.customVoiceRules = voiceRulesText;
|
||||
}
|
||||
|
||||
+221
-32
@@ -1,9 +1,19 @@
|
||||
/**
|
||||
* PENNY - Anthropic Claude Provider
|
||||
*
|
||||
* Implements LLMService for the Anthropic Messages API.
|
||||
* 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)
|
||||
*
|
||||
* Uses an injected HTTP function (Obsidian's requestUrl) so this module
|
||||
* has no direct Obsidian API dependency.
|
||||
* has no direct Obsidian API dependency and can be tested with a mock.
|
||||
*/
|
||||
|
||||
import type {
|
||||
@@ -14,11 +24,12 @@ 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 extended thinking feature. */
|
||||
/** Models that support the adaptive / extended thinking feature (Claude 4.6+). */
|
||||
const THINKING_CAPABLE = new Set(["claude-opus-4-6", "claude-sonnet-4-6"]);
|
||||
|
||||
/**
|
||||
@@ -27,19 +38,22 @@ 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: 200000,
|
||||
contextWindow: 1000000,
|
||||
costPer1kInput: 0.015,
|
||||
costPer1kOutput: 0.075,
|
||||
},
|
||||
{
|
||||
id: "claude-sonnet-4-6",
|
||||
name: "Claude Sonnet 4.6",
|
||||
contextWindow: 200000,
|
||||
contextWindow: 1000000,
|
||||
costPer1kInput: 0.003,
|
||||
costPer1kOutput: 0.015,
|
||||
},
|
||||
@@ -52,12 +66,13 @@ export const ANTHROPIC_MODELS: ModelInfo[] = [
|
||||
},
|
||||
];
|
||||
|
||||
/** Token estimation multiplier: ~1.33 tokens per word for Anthropic models. */
|
||||
/** Token estimation multiplier: ~1.33 tokens per word for Claude's tokenizer. */
|
||||
const TOKEN_MULTIPLIER = 1.33;
|
||||
|
||||
export class AnthropicProvider implements LLMService {
|
||||
readonly name = "anthropic";
|
||||
readonly requiresApiKey = true;
|
||||
readonly tokenMultiplier = 1.33;
|
||||
|
||||
constructor(private httpFn: HttpFn) {}
|
||||
|
||||
@@ -71,6 +86,55 @@ 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,
|
||||
@@ -80,30 +144,150 @@ export class AnthropicProvider implements LLMService {
|
||||
],
|
||||
};
|
||||
|
||||
// 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)) {
|
||||
const useThinking = request.useThinking === true && THINKING_CAPABLE.has(request.model);
|
||||
if (useThinking) {
|
||||
body.thinking = { type: "adaptive" };
|
||||
}
|
||||
|
||||
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),
|
||||
});
|
||||
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,
|
||||
);
|
||||
|
||||
if (response.status < 200 || response.status >= 300) {
|
||||
throw this.buildHttpError(response.status, response.text);
|
||||
throw this.buildHttpError(response.status, response.text, response.headers);
|
||||
}
|
||||
|
||||
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);
|
||||
@@ -135,7 +319,7 @@ export class AnthropicProvider implements LLMService {
|
||||
if (response.status >= 200 && response.status < 300) {
|
||||
return null; // success
|
||||
}
|
||||
return this.buildHttpError(response.status, response.text).message;
|
||||
return this.buildHttpError(response.status, response.text, response.headers).message;
|
||||
} catch (err: unknown) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
return `Anthropic connection failed: ${message}`;
|
||||
@@ -143,10 +327,15 @@ export class AnthropicProvider implements LLMService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a descriptive Error from an Anthropic HTTP error response.
|
||||
* Attempts to parse the Anthropic error JSON format; falls back to raw text.
|
||||
* 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.
|
||||
*/
|
||||
private buildHttpError(status: number, responseText: string): Error {
|
||||
private buildHttpError(
|
||||
status: number,
|
||||
responseText: string,
|
||||
headers: Record<string, string> = {},
|
||||
): HttpError {
|
||||
let detail: string;
|
||||
try {
|
||||
const data = JSON.parse(responseText);
|
||||
@@ -159,13 +348,13 @@ export class AnthropicProvider implements LLMService {
|
||||
detail = responseText;
|
||||
}
|
||||
|
||||
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}`);
|
||||
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);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -200,7 +389,7 @@ export class AnthropicProvider implements LLMService {
|
||||
(block: Record<string, unknown>) => block.type === "text",
|
||||
);
|
||||
if (textBlock && typeof textBlock.text === "string") {
|
||||
text = textBlock.text.trim();
|
||||
text = textBlock.text;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,350 @@
|
||||
/**
|
||||
* 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
+12
-3
@@ -1,9 +1,12 @@
|
||||
/**
|
||||
* PENNY - Provider Exports
|
||||
*
|
||||
* 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.
|
||||
* 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.
|
||||
*/
|
||||
|
||||
// Service interface and types
|
||||
@@ -35,6 +38,8 @@ 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 --
|
||||
|
||||
@@ -42,6 +47,8 @@ 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.
|
||||
@@ -61,5 +68,7 @@ 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;
|
||||
}
|
||||
@@ -0,0 +1,390 @@
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
+159
-18
@@ -1,13 +1,23 @@
|
||||
/**
|
||||
* PENNY - Ollama Provider
|
||||
*
|
||||
* 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.
|
||||
* 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.
|
||||
*
|
||||
* Ollama exposes two relevant APIs:
|
||||
* - /api/tags -- list locally available models
|
||||
* - /v1/chat/completions -- OpenAI-compatible chat completions
|
||||
* 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
|
||||
*
|
||||
* 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 {
|
||||
@@ -18,6 +28,7 @@ import type {
|
||||
ProviderSettings,
|
||||
HttpFn,
|
||||
} from "./service";
|
||||
import { streamRequest, withTimeout, withRetry, HttpError } from "./node-stream";
|
||||
|
||||
const DEFAULT_OLLAMA_ENDPOINT = "http://localhost:11434";
|
||||
|
||||
@@ -32,6 +43,7 @@ const TOKEN_MULTIPLIER = 1.0;
|
||||
export class OllamaProvider implements LLMService {
|
||||
readonly name = "ollama";
|
||||
readonly requiresApiKey = false;
|
||||
readonly tokenMultiplier = 1.0;
|
||||
|
||||
constructor(private httpFn: HttpFn) {}
|
||||
|
||||
@@ -86,8 +98,46 @@ 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: [
|
||||
@@ -107,12 +157,15 @@ export class OllamaProvider implements LLMService {
|
||||
|
||||
let response;
|
||||
try {
|
||||
response = await this.httpFn({
|
||||
url: `${endpoint}/v1/chat/completions`,
|
||||
method: "POST",
|
||||
headers,
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
response = await withTimeout(
|
||||
this.httpFn({
|
||||
url: `${endpoint}/v1/chat/completions`,
|
||||
method: "POST",
|
||||
headers,
|
||||
body: JSON.stringify(body),
|
||||
}),
|
||||
request.timeoutMs,
|
||||
);
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
if (message.includes("ECONNREFUSED") || message.includes("fetch failed") || message.includes("Connection refused")) {
|
||||
@@ -122,12 +175,94 @@ export class OllamaProvider implements LLMService {
|
||||
}
|
||||
|
||||
if (response.status < 200 || response.status >= 300) {
|
||||
throw this.buildHttpError(response.status, response.text, endpoint);
|
||||
throw this.buildHttpError(response.status, response.text, endpoint, response.headers);
|
||||
}
|
||||
|
||||
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);
|
||||
@@ -145,7 +280,7 @@ export class OllamaProvider implements LLMService {
|
||||
if (response.status >= 200 && response.status < 300) {
|
||||
return null; // success
|
||||
}
|
||||
return this.buildHttpError(response.status, response.text, endpoint).message;
|
||||
return this.buildHttpError(response.status, response.text, endpoint, response.headers).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")) {
|
||||
@@ -156,9 +291,15 @@ export class OllamaProvider implements LLMService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a descriptive Error from an Ollama HTTP error response.
|
||||
* Build a typed HttpError from an Ollama HTTP error response. Attaches
|
||||
* status + headers so the retry helper can read Retry-After.
|
||||
*/
|
||||
private buildHttpError(status: number, responseText: string, _endpoint: string): Error {
|
||||
private buildHttpError(
|
||||
status: number,
|
||||
responseText: string,
|
||||
_endpoint: string,
|
||||
headers: Record<string, string> = {},
|
||||
): HttpError {
|
||||
let detail: string;
|
||||
try {
|
||||
const data = JSON.parse(responseText);
|
||||
@@ -166,7 +307,7 @@ export class OllamaProvider implements LLMService {
|
||||
} catch {
|
||||
detail = responseText;
|
||||
}
|
||||
return new Error(`Ollama error (${status}): ${detail}`);
|
||||
return new HttpError(status, headers, responseText, `Ollama error (${status}): ${detail}`);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -198,7 +339,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.trim();
|
||||
text = choice.message.content;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,330 @@
|
||||
/**
|
||||
* 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,13 +1,20 @@
|
||||
/**
|
||||
* PENNY - Provider Registry
|
||||
*
|
||||
* 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.
|
||||
* 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.
|
||||
*/
|
||||
|
||||
import type { LLMService } from "./service";
|
||||
|
||||
/** Simple name-keyed store for LLM provider instances. */
|
||||
export class ProviderRegistry {
|
||||
private providers: Map<string, LLMService> = new Map();
|
||||
|
||||
|
||||
+21
-4
@@ -4,11 +4,19 @@
|
||||
* Maps annotation tags to complexity tiers, then resolves the tier to a
|
||||
* specific provider + model via a user-configured RouteConfig.
|
||||
*
|
||||
* Light tasks (CUT, PACING) can use cheaper/faster models.
|
||||
* Heavy tasks (REWRITE, DIALOG, CHARACTER) get the strongest model.
|
||||
* Standard is the middle ground.
|
||||
* Tier assignments:
|
||||
* - Light: CUT, PACING (simple edits, cheaper/faster models)
|
||||
* - Standard: TONE, EXPAND, PLOT (moderate complexity)
|
||||
* - Heavy: REWRITE, DIALOG, CHARACTER (needs strongest model)
|
||||
*
|
||||
* 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. */
|
||||
@@ -46,6 +54,10 @@ 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";
|
||||
@@ -62,7 +74,12 @@ 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.
|
||||
* 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
|
||||
*/
|
||||
export function resolveModel(model: string, tier: string): string {
|
||||
if (model === "auto-latest") {
|
||||
|
||||
+56
-14
@@ -1,11 +1,20 @@
|
||||
/**
|
||||
* PENNY - LLM Service Interface
|
||||
*
|
||||
* Every LLM provider implements this interface. Adding a new provider means
|
||||
* implementing LLMService and registering it with the ProviderRegistry.
|
||||
* 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.
|
||||
*/
|
||||
|
||||
/** HTTP request parameters -- mirrors Obsidian's RequestUrlParam shape. */
|
||||
/** HTTP request parameters. Mirrors Obsidian's RequestUrlParam shape so the adapter is thin. */
|
||||
export interface HttpRequestParam {
|
||||
url: string;
|
||||
method?: string;
|
||||
@@ -14,7 +23,7 @@ export interface HttpRequestParam {
|
||||
contentType?: string;
|
||||
}
|
||||
|
||||
/** HTTP response shape -- mirrors Obsidian's RequestUrlResponse. */
|
||||
/** HTTP response shape. Mirrors Obsidian's RequestUrlResponse for adapter compatibility. */
|
||||
export interface HttpResponse {
|
||||
status: number;
|
||||
headers: Record<string, string>;
|
||||
@@ -22,28 +31,34 @@ export interface HttpResponse {
|
||||
json: unknown;
|
||||
}
|
||||
|
||||
/** Injectable HTTP function type (Obsidian's requestUrl or a test mock). */
|
||||
/** Injectable HTTP function type. Obsidian's requestUrl at runtime; a mock in tests. */
|
||||
export type HttpFn = (params: HttpRequestParam) => Promise<HttpResponse>;
|
||||
|
||||
/** Every LLM provider implements this interface. */
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
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 fetched from endpoint). */
|
||||
/** Available models for this provider (static list or dynamically fetched from endpoint). */
|
||||
getModels(settings?: ProviderSettings): Promise<ModelInfo[]>;
|
||||
|
||||
/** Send a prompt and get a response. */
|
||||
/** Send a prompt and get a completion response. The core LLM call. */
|
||||
complete(request: CompletionRequest): Promise<CompletionResponse>;
|
||||
|
||||
/** Rough token estimate for this provider's tokenizer. */
|
||||
/** Rough token estimate for this provider's tokenizer (words * multiplier). */
|
||||
estimateTokens(text: string): number;
|
||||
|
||||
/** Test connectivity. Returns error message or null if OK. */
|
||||
/** Test connectivity and authentication. Returns an error message or null if OK. */
|
||||
testConnection(settings: ProviderSettings): Promise<string | null>;
|
||||
}
|
||||
|
||||
/** Metadata about a model available from a provider. */
|
||||
/** Metadata about a model available from a provider. Used in settings dropdowns and cost estimation. */
|
||||
export interface ModelInfo {
|
||||
id: string;
|
||||
name: string;
|
||||
@@ -52,7 +67,7 @@ export interface ModelInfo {
|
||||
costPer1kOutput?: number;
|
||||
}
|
||||
|
||||
/** Request sent to an LLM provider. */
|
||||
/** Request sent to an LLM provider. Built by drafter.ts, dispatched by pipeline.ts. */
|
||||
export interface CompletionRequest {
|
||||
systemPrompt: string;
|
||||
userPrompt: string;
|
||||
@@ -62,9 +77,36 @@ 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. */
|
||||
/** Response from an LLM provider. The `text` field contains the revised prose. */
|
||||
export interface CompletionResponse {
|
||||
text: string;
|
||||
usage?: { inputTokens?: number; outputTokens?: number };
|
||||
@@ -72,7 +114,7 @@ export interface CompletionResponse {
|
||||
provider: string;
|
||||
}
|
||||
|
||||
/** Per-provider settings (API keys, endpoints, etc.). */
|
||||
/** Per-provider settings passed to getModels() and testConnection(). Allows arbitrary extra fields via index signature. */
|
||||
export interface ProviderSettings {
|
||||
apiKey?: string;
|
||||
endpoint?: string;
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
/**
|
||||
* 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];
|
||||
}
|
||||
@@ -2,6 +2,15 @@
|
||||
* 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.
|
||||
*/
|
||||
|
||||
|
||||
+728
-52
@@ -1,14 +1,30 @@
|
||||
/**
|
||||
* PENNY - Settings Tab
|
||||
*
|
||||
* Full settings UI with collapsible sections for API, project structure,
|
||||
* voice rules, behavior, and git integration.
|
||||
* 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).
|
||||
*/
|
||||
|
||||
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";
|
||||
|
||||
/**
|
||||
@@ -66,7 +82,9 @@ 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);
|
||||
}
|
||||
|
||||
@@ -104,7 +122,7 @@ export class PennySettingTab extends PluginSettingTab {
|
||||
);
|
||||
|
||||
// Anthropic models -- show static list + fetch from API if key is set
|
||||
const anthropicModelsSetting = new Setting(details)
|
||||
new Setting(details)
|
||||
.setName("Available Claude models")
|
||||
.setDesc("Models available for use with Anthropic.");
|
||||
const anthropicModelListEl = details.createEl("div", { cls: "penny-model-list" });
|
||||
@@ -125,10 +143,9 @@ export class PennySettingTab extends PluginSettingTab {
|
||||
apiKey: this.plugin.settings.anthropicApiKey,
|
||||
});
|
||||
if (result) {
|
||||
new Notice(
|
||||
`PENNY: Anthropic connection failed -- ${result}`,
|
||||
6000,
|
||||
);
|
||||
showPennyError(this.app,
|
||||
"Anthropic connection failed",
|
||||
result);
|
||||
} else {
|
||||
new Notice(
|
||||
"PENNY: Anthropic connection OK.",
|
||||
@@ -137,10 +154,7 @@ export class PennySettingTab extends PluginSettingTab {
|
||||
}
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
new Notice(
|
||||
`PENNY: Test failed -- ${msg}`,
|
||||
6000,
|
||||
);
|
||||
showPennyError(this.app, "Anthropic test failed", msg);
|
||||
} finally {
|
||||
button.setButtonText("Test Connection");
|
||||
button.setDisabled(false);
|
||||
@@ -164,7 +178,14 @@ export class PennySettingTab extends PluginSettingTab {
|
||||
t.inputEl.style.width = "300px";
|
||||
})
|
||||
.onChange(async (value) => {
|
||||
this.plugin.settings.ollamaEndpoint = value.trim();
|
||||
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;
|
||||
await this.plugin.saveSettings();
|
||||
})
|
||||
);
|
||||
@@ -218,10 +239,9 @@ export class PennySettingTab extends PluginSettingTab {
|
||||
apiKey: this.plugin.settings.ollamaApiKey,
|
||||
});
|
||||
if (result) {
|
||||
new Notice(
|
||||
`PENNY: Ollama test failed -- ${result}`,
|
||||
6000,
|
||||
);
|
||||
showPennyError(this.app,
|
||||
"Ollama connection failed",
|
||||
result);
|
||||
} else {
|
||||
new Notice(
|
||||
"PENNY: Ollama connection OK.",
|
||||
@@ -230,10 +250,131 @@ export class PennySettingTab extends PluginSettingTab {
|
||||
}
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
new Notice(
|
||||
`PENNY: Ollama test failed -- ${msg}`,
|
||||
6000,
|
||||
);
|
||||
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);
|
||||
} finally {
|
||||
button.setButtonText("Test Connection");
|
||||
button.setDisabled(false);
|
||||
@@ -271,13 +412,22 @@ 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) {
|
||||
// Single row -- edits routeStandard and mirrors to light/heavy
|
||||
this.renderRouteRow(details, "All tiers", "routeStandard", providerNames, true);
|
||||
this.renderRouteRow(grid, "All tiers", "routeStandard", providerNames, true);
|
||||
} else {
|
||||
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);
|
||||
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);
|
||||
}
|
||||
|
||||
// Context budget (moved here from old API section since it relates to routing)
|
||||
@@ -292,10 +442,35 @@ export class PennySettingTab extends PluginSettingTab {
|
||||
.setValue(String(this.plugin.settings.contextBudget))
|
||||
.onChange(async (value) => {
|
||||
const parsed = parseInt(value, 10);
|
||||
if (!isNaN(parsed) && parsed > 0) {
|
||||
this.plugin.settings.contextBudget = parsed;
|
||||
await this.plugin.saveSettings();
|
||||
if (isNaN(parsed) || parsed <= 0) {
|
||||
new Notice("PENNY: Context budget must be a positive number.");
|
||||
text.setValue(String(this.plugin.settings.contextBudget));
|
||||
return;
|
||||
}
|
||||
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();
|
||||
})
|
||||
);
|
||||
}
|
||||
@@ -309,13 +484,15 @@ export class PennySettingTab extends PluginSettingTab {
|
||||
private renderRouteRow(
|
||||
container: HTMLElement,
|
||||
label: string,
|
||||
routeKey: "routeLight" | "routeStandard" | "routeHeavy",
|
||||
routeKey: "routeLight" | "routeStandard" | "routeHeavy" | "routeResearch",
|
||||
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);
|
||||
|
||||
@@ -327,9 +504,13 @@ export class PennySettingTab extends PluginSettingTab {
|
||||
dropdown.setValue(route.provider);
|
||||
dropdown.onChange(async (value) => {
|
||||
route.provider = value;
|
||||
// When switching to Ollama, keep model text; switching to Anthropic, default to sonnet
|
||||
// When switching providers, default to a sensible model if the current one doesn't match
|
||||
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 };
|
||||
@@ -340,14 +521,18 @@ export class PennySettingTab extends PluginSettingTab {
|
||||
});
|
||||
});
|
||||
|
||||
// Model selector: dropdown for Anthropic, text input for Ollama
|
||||
// Model selector: dropdown for providers with static catalogs, 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 autoResolvedModel = resolveModel("auto-latest", tierLabel);
|
||||
const autoLabel = `Auto (recommended) -> ${autoResolvedModel}`;
|
||||
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})`;
|
||||
|
||||
setting.addDropdown((dropdown) => {
|
||||
dropdown.addOption("auto-latest", autoLabel);
|
||||
@@ -364,6 +549,36 @@ 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) {
|
||||
@@ -432,7 +647,7 @@ export class PennySettingTab extends PluginSettingTab {
|
||||
);
|
||||
|
||||
const pathSettings: Array<{
|
||||
key: keyof typeof this.plugin.settings;
|
||||
key: keyof PennySettings;
|
||||
name: string;
|
||||
desc: string;
|
||||
placeholder: string;
|
||||
@@ -491,6 +706,12 @@ 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",
|
||||
@@ -592,6 +813,9 @@ 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();
|
||||
})
|
||||
@@ -641,19 +865,93 @@ export class PennySettingTab extends PluginSettingTab {
|
||||
);
|
||||
|
||||
new Setting(details)
|
||||
.setName("Verbose logging")
|
||||
.setName("Request timeout (seconds)")
|
||||
.setDesc(
|
||||
"Write detailed activity entries to the log. Useful for debugging but increases log file size."
|
||||
"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."
|
||||
)
|
||||
.addToggle((toggle) =>
|
||||
toggle
|
||||
.setValue(this.plugin.settings.verboseLogging)
|
||||
.addText((text) =>
|
||||
text
|
||||
.setPlaceholder("300")
|
||||
.setValue(String(Math.round(this.plugin.settings.requestTimeoutMs / 1000)))
|
||||
.onChange(async (value) => {
|
||||
this.plugin.settings.verboseLogging = 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."
|
||||
)
|
||||
.addToggle((toggle) =>
|
||||
toggle
|
||||
.setValue(this.plugin.settings.showProgressModal)
|
||||
.onChange(async (value) => {
|
||||
this.plugin.settings.showProgressModal = 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(
|
||||
@@ -752,6 +1050,42 @@ 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[];
|
||||
@@ -804,9 +1138,7 @@ export class PennySettingTab extends PluginSettingTab {
|
||||
`PENNY: Detected ${detectedCount} path(s):\n${detected.join("\n")}`,
|
||||
8000,
|
||||
);
|
||||
// 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.
|
||||
this.redisplayPreservingState();
|
||||
} else {
|
||||
new Notice(
|
||||
"PENNY: No project structure detected, or all paths are already configured.",
|
||||
@@ -815,6 +1147,24 @@ 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.
|
||||
*/
|
||||
@@ -863,6 +1213,54 @@ 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.
|
||||
*/
|
||||
@@ -880,21 +1278,29 @@ 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
|
||||
})
|
||||
);
|
||||
|
||||
new Setting(details)
|
||||
const autoPushSetting = new Setting(details)
|
||||
.setName("Auto-push after commit")
|
||||
.setDesc("Automatically push to remote after committing.")
|
||||
.addToggle((toggle) =>
|
||||
.setDesc("Automatically push to remote after committing. Requires 'Auto-commit after processing' to be enabled.")
|
||||
.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")
|
||||
@@ -928,16 +1334,17 @@ 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" },
|
||||
{ name: "PENNY: Process this chapter", desc: "Process annotations in the active chapter (Cmd/Ctrl+Shift+P)" },
|
||||
{ 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" },
|
||||
{ name: "PENNY: Dry run", desc: "Show what would change without processing (Cmd/Ctrl+Shift+D)" },
|
||||
{ name: "PENNY: Migrate chapters", desc: "Convert flat chapter files to versioned folders" },
|
||||
{ name: "PENNY: Show status", desc: "Show annotation counts and version info" },
|
||||
{ name: "PENNY: Show status", desc: "Show annotation counts and version info (Cmd/Ctrl+Shift+S)" },
|
||||
{ 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" });
|
||||
@@ -953,9 +1360,278 @@ 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("Press Cmd/Ctrl+P and type \"PENNY\" to see all commands.");
|
||||
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)}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,327 @@
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
}
|
||||
+11
-2
@@ -1,8 +1,17 @@
|
||||
/**
|
||||
* PENNY - Status Bar Widget
|
||||
*
|
||||
* Manages the status bar element in Obsidian's footer.
|
||||
* Shows annotation counts, active chapter info, and processing state.
|
||||
* 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).
|
||||
*/
|
||||
|
||||
import type { TFile } from "obsidian";
|
||||
|
||||
+293
-16
@@ -1,60 +1,170 @@
|
||||
/**
|
||||
* PENNY - Prose Engine for Narrative, Notes, and Yarns
|
||||
* Shared type definitions
|
||||
* 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.
|
||||
*/
|
||||
|
||||
/** Route configuration for a complexity tier */
|
||||
/**
|
||||
* 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
|
||||
*/
|
||||
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 stored in Obsidian's data.json */
|
||||
/**
|
||||
* 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).
|
||||
*/
|
||||
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;
|
||||
verboseLogging: boolean;
|
||||
/** Controls console log verbosity. */
|
||||
logLevel: LogLevel;
|
||||
/** Glob pattern identifying chapter files within book folders (e.g. "ch-*.md"). */
|
||||
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 placeholders */
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
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}
|
||||
@@ -74,6 +184,12 @@ 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)
|
||||
@@ -93,6 +209,9 @@ 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>,
|
||||
@@ -116,23 +235,37 @@ 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: "",
|
||||
@@ -149,16 +282,28 @@ export const DEFAULT_SETTINGS: PennySettings = {
|
||||
systemPromptTemplate: DEFAULT_SYSTEM_PROMPT,
|
||||
|
||||
autoProcessOnSave: false,
|
||||
verboseLogging: false,
|
||||
logLevel: "debug",
|
||||
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}",
|
||||
};
|
||||
|
||||
/** Supported annotation tags */
|
||||
/**
|
||||
* Tags that trigger LLM processing. Each annotation with one of these tags
|
||||
* produces an API call to revise the targeted passage.
|
||||
*/
|
||||
export const ACTIONABLE_TAGS = [
|
||||
"REWRITE",
|
||||
"EXPAND",
|
||||
@@ -170,55 +315,114 @@ 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];
|
||||
|
||||
/** Parsed annotation from a chapter file */
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
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;
|
||||
scope: "inline" | "paragraph" | "section";
|
||||
/** 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. */
|
||||
actionable: boolean;
|
||||
/** Deterministic hash of (tag, instruction, originalText) for idempotency tracking. */
|
||||
hash: string;
|
||||
}
|
||||
|
||||
/** Chapter version state */
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
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;
|
||||
}
|
||||
|
||||
/** Context assembled for a revision prompt */
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
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;
|
||||
}
|
||||
|
||||
/** Chapter frontmatter */
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
export interface ChapterFrontmatter {
|
||||
type?: string;
|
||||
book?: number;
|
||||
@@ -227,79 +431,152 @@ 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
|
||||
// Agent-managed fields (updated by pipeline.ts after processing)
|
||||
/** Version number set by PENNY after creating a new version. */
|
||||
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;
|
||||
}
|
||||
|
||||
/** Review note data */
|
||||
/**
|
||||
* Complete data for a review note generated after a processing pass.
|
||||
* Passed to reviewer.ts to produce the markdown review file.
|
||||
*/
|
||||
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[];
|
||||
}
|
||||
|
||||
/** Activity log entry */
|
||||
/**
|
||||
* Single entry in the JSONL activity log.
|
||||
* One entry per processing pass, appended to {book}-activity.jsonl.
|
||||
*/
|
||||
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;
|
||||
}
|
||||
|
||||
/** Project init wizard options */
|
||||
/**
|
||||
* Options for the project initialization wizard (ProjectInitModal in commands.ts).
|
||||
* Drives folder and file scaffolding for a new novel project.
|
||||
*/
|
||||
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";
|
||||
}
|
||||
@@ -2,6 +2,10 @@
|
||||
* 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.
|
||||
*/
|
||||
|
||||
/**
|
||||
|
||||
+33
-6
@@ -1,7 +1,16 @@
|
||||
/**
|
||||
* PENNY - Version Management
|
||||
*
|
||||
* Manages .version manifest and .state.json files.
|
||||
* 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.
|
||||
*
|
||||
* Pure functions -- no Obsidian API dependencies.
|
||||
*/
|
||||
|
||||
@@ -9,7 +18,9 @@ import type { AnnotatedSection, VersionState, ProcessedAnnotation } from "./type
|
||||
|
||||
/**
|
||||
* Read the current version number from .version file content.
|
||||
* Returns 0 if the content is empty or unparseable.
|
||||
*
|
||||
* @param versionContent - Raw text content of the `.version` file
|
||||
* @returns The parsed version number, or 0 if empty/unparseable/negative
|
||||
*/
|
||||
export function readVersion(versionContent: string): number {
|
||||
const trimmed = versionContent.trim();
|
||||
@@ -18,15 +29,21 @@ export function readVersion(versionContent: string): number {
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute the next version number.
|
||||
* Compute the next version number (simple increment).
|
||||
*
|
||||
* @param current - The current version number
|
||||
* @returns current + 1
|
||||
*/
|
||||
export function nextVersion(current: number): number {
|
||||
return current + 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse .state.json content into a VersionState.
|
||||
* Returns a default state if the content is empty or invalid JSON.
|
||||
* 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)
|
||||
*/
|
||||
export function readState(stateContent: string): VersionState {
|
||||
const defaultState: VersionState = {
|
||||
@@ -101,6 +118,13 @@ 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[],
|
||||
@@ -111,7 +135,10 @@ export function shouldProcess(
|
||||
}
|
||||
|
||||
/**
|
||||
* Serialize a VersionState to JSON for writing to .state.json.
|
||||
* 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
|
||||
*/
|
||||
export function serializeState(state: VersionState): string {
|
||||
return JSON.stringify(state, null, 2);
|
||||
|
||||
+54
-12
@@ -1,17 +1,29 @@
|
||||
/**
|
||||
* PENNY - Voice Compliance
|
||||
*
|
||||
* Analyze prose for voice compliance: dialogue ratio, long narration
|
||||
* sentences, and self-analysis patterns.
|
||||
* 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.
|
||||
*
|
||||
* Pure function -- no Obsidian API dependencies.
|
||||
*/
|
||||
|
||||
import type { VoiceComplianceResult } from "./types";
|
||||
|
||||
/** Maximum allowed words in a narration sentence before flagging. */
|
||||
/** Maximum allowed words in a narration sentence before flagging it as "long". */
|
||||
const MAX_NARRATION_WORDS = 20;
|
||||
|
||||
/** Patterns that indicate self-analysis / internal monologue. */
|
||||
/**
|
||||
* 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).
|
||||
*/
|
||||
const SELF_ANALYSIS_PATTERNS: RegExp[] = [
|
||||
/\bmy brain\b/i,
|
||||
/\bI feel\b/i,
|
||||
@@ -46,20 +58,50 @@ function splitSentences(text: string): string[] {
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract dialogue word count from prose.
|
||||
* Dialogue = text enclosed in quotation marks (straight or curly).
|
||||
* 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.
|
||||
*/
|
||||
function extractDialogueWords(text: string): number {
|
||||
const dialogueRe = /[""\u201C](.*?)[""\u201D]/g;
|
||||
let count = 0;
|
||||
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;
|
||||
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;
|
||||
}
|
||||
}
|
||||
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;
|
||||
@@ -80,7 +122,7 @@ export function checkVoiceCompliance(prose: string): VoiceComplianceResult {
|
||||
|
||||
// --- Long narration sentences ---
|
||||
// Remove dialogue from the text to isolate narration.
|
||||
const narration = prose.replace(/[""\u201C].*?[""\u201D]/g, "");
|
||||
const narration = stripDialogue(prose);
|
||||
const sentences = splitSentences(narration);
|
||||
const longNarrationSentences: string[] = [];
|
||||
for (const sentence of sentences) {
|
||||
|
||||
+566
@@ -1,5 +1,185 @@
|
||||
/* 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;
|
||||
@@ -204,3 +384,389 @@
|
||||
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;
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
/**
|
||||
* 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);
|
||||
});
|
||||
});
|
||||
@@ -383,6 +383,30 @@ 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");
|
||||
|
||||
+272
-8
@@ -349,14 +349,8 @@ Some prose without any annotations.
|
||||
|
||||
const result = await runPipeline(input);
|
||||
|
||||
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);
|
||||
// When all annotations fail, pipeline returns null (no version created)
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it("updates frontmatter with agent fields", async () => {
|
||||
@@ -457,4 +451,274 @@ 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");
|
||||
});
|
||||
});
|
||||
@@ -327,4 +327,183 @@ 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");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,305 @@
|
||||
/**
|
||||
* 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);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,122 @@
|
||||
/**
|
||||
* 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,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,247 @@
|
||||
/**
|
||||
* 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");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,75 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -92,7 +92,7 @@ describe("AnthropicProvider", () => {
|
||||
|
||||
const opus = models.find((m) => m.id === "claude-opus-4-6");
|
||||
expect(opus).toBeDefined();
|
||||
expect(opus!.contextWindow).toBe(200000);
|
||||
expect(opus!.contextWindow).toBe(1000000);
|
||||
expect(opus!.costPer1kInput).toBeGreaterThan(0);
|
||||
expect(opus!.costPer1kOutput).toBeGreaterThan(0);
|
||||
});
|
||||
@@ -188,6 +188,32 @@ 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 () => {
|
||||
@@ -270,6 +296,18 @@ 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" },
|
||||
@@ -300,8 +338,9 @@ describe("AnthropicProvider", () => {
|
||||
const { fn } = mockHttp({ status: 429, text: errorResponse });
|
||||
const provider = new AnthropicProvider(fn);
|
||||
|
||||
await expect(provider.complete(makeRequest())).rejects.toThrow("Rate limited");
|
||||
await expect(provider.complete(makeRequest())).rejects.toThrow("429");
|
||||
// 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 () => {
|
||||
@@ -311,15 +350,15 @@ describe("AnthropicProvider", () => {
|
||||
const { fn } = mockHttp({ status: 500, text: errorResponse });
|
||||
const provider = new AnthropicProvider(fn);
|
||||
|
||||
await expect(provider.complete(makeRequest())).rejects.toThrow("Anthropic API error (500)");
|
||||
await expect(provider.complete(makeRequest())).rejects.toThrow("Internal server error");
|
||||
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");
|
||||
});
|
||||
|
||||
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())).rejects.toThrow("Anthropic API error (502): Bad Gateway");
|
||||
await expect(provider.complete(makeRequest({ maxRetries: 0 }))).rejects.toThrow("Anthropic API error (502): Bad Gateway");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -354,6 +393,69 @@ 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");
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
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" });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,392 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,39 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,58 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { OllamaProvider } from "../../src/providers/ollama";
|
||||
import type { HttpFn, HttpResponse, CompletionRequest } from "../../src/providers/service";
|
||||
|
||||
@@ -278,18 +278,85 @@ describe("OllamaProvider", () => {
|
||||
const { fn } = mockHttp({ status: 500, text: "Internal Server Error" });
|
||||
const provider = new OllamaProvider(fn);
|
||||
|
||||
await expect(provider.complete(makeRequest())).rejects.toThrow("Ollama error (500): Internal Server Error");
|
||||
// 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");
|
||||
});
|
||||
|
||||
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);
|
||||
|
||||
await expect(
|
||||
provider.complete(makeRequest()),
|
||||
).rejects.toThrow("Cannot connect to Ollama at http://localhost:11434. Is Ollama running?");
|
||||
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;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
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" });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,402 @@
|
||||
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");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,254 @@
|
||||
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");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,257 @@
|
||||
/**
|
||||
* 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");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,301 @@
|
||||
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");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,37 @@
|
||||
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]);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,409 @@
|
||||
/**
|
||||
* 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");
|
||||
});
|
||||
});
|
||||
@@ -34,6 +34,42 @@ 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);
|
||||
|
||||
+5
-2
@@ -6,11 +6,14 @@
|
||||
"module": "ESNext",
|
||||
"target": "ES2018",
|
||||
"allowJs": true,
|
||||
"noImplicitAny": true,
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noImplicitReturns": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"moduleResolution": "node",
|
||||
"importHelpers": true,
|
||||
"isolatedModules": true,
|
||||
"strictNullChecks": true,
|
||||
"lib": ["DOM", "ES5", "ES6", "ES7"],
|
||||
"outDir": "./dist",
|
||||
"paths": {
|
||||
|
||||
@@ -4,5 +4,59 @@ 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 },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
Reference in new issue
Block a user