Compare commits

...
Author SHA1 Message Date
james.maes 06dec906bf Add .circleci/config.yml 2026-04-16 15:14:36 -05:00
james.maes db532bef96 feat: Ollama model dropdown, path browse buttons, detect fix
Ollama: model selector is now a dropdown (populated from cached models)
instead of text input. Falls back to text if models not yet fetched.

Project Structure: each path setting has a Browse button that opens a
fuzzy suggest modal for picking vault folders/files.

Detect Structure: no longer closes the section (removed this.display()
re-render).
2026-04-16 15:13:25 -05:00
james.maes 01f042a41b feat: redesign settings header, add dynamic model lists
Header: gradient background, rounded card, version badge, pill-style
link buttons with hover states.

Model lists: Anthropic shows static catalog with context window sizes.
Ollama auto-fetches installed models on settings load with Refresh
button. Both display in styled panels within their provider sections.
2026-04-16 14:53:11 -05:00
james.maes e20e29d2d0 fix: point doc links back to main branch 2026-04-16 14:15:21 -05:00
james.maes e026d72753 fix: round 7 audit - inline overlap, auto-save guard, hash entropy, doc links
HIGH: Don't treat same-line inline annotations as overlapping (both get
processed). Add requireProvider check in auto-save debounce callback.

MEDIUM: Use slice(-12) for hash to keep most-entropic hex digits.

Fix doc links in settings header to point to develop branch.
Export requireProvider for use in main.ts save hook.
2026-04-16 14:12:47 -05:00
james.maes 036a58b30c fix: address round 6 audit - git staging, processing guard, budget gate
HIGH: Replace git add -A with specific file staging (only PENNY-created
paths). Add review/log paths to processingFiles guard. Gate voiceTests
on remaining budget > 0.

MEDIUM: Detect version file collisions by scanning for highest existing
version. Fix git status --porcelain rename/copy path parsing.

310 tests passing.
2026-04-16 14:02:52 -05:00
james.maes 6f70b3a93c fix: address round 5 audit - adaptive thinking, overlap surfacing, path validation
CRITICAL: Switch from deprecated budget_tokens thinking to adaptive
thinking (type: "adaptive") for Claude 4.6 models.

HIGH: Gate useThinking on provider === "anthropic" (not sent to Ollama).
Change assembleNewVersion to return AssemblyResult with skippedOverlaps,
surface skipped overlaps in review flags.

MEDIUM: Add path validation for project names (reject traversal and
special chars). Update tests for adaptive thinking and AssemblyResult.

310 tests passing.
2026-04-16 13:51:22 -05:00
james.maes 1e90a415cd feat: settings UI improvements - auto models, detect, validate, help
Auto-latest model: "Auto (recommended)" option for each tier, resolves
to best model at runtime (Haiku/Sonnet/Opus per tier). Default for all
new installs.

Detect project structure: button scans vault for standard folder
patterns and auto-fills empty path settings.

Validate paths: button checks all configured paths exist, reports
OK/MISSING/not set for each.

Commands reference: collapsible help section at bottom of settings
with all 11 commands in a styled table.

Ollama refresh models: button fetches installed models from Ollama
endpoint, shows them in a notice.

310 tests passing (8 new router tests for resolveModel).
2026-04-16 13:39:23 -05:00
james.maes 6dd234d21e fix: address all round 4 audit findings
HIGH: Fix API key leaking to Ollama bearer header (use per-provider
credentials). Make readFolderFiles recursive (nested character/wiki
files now found).

MEDIUM: Preserve frontmatter key order on roundtrip (reduces git diff
noise). Suppress per-chapter notices in batch mode (single summary).
Pre-compute output paths for processingFiles guard. Guard against
absolute paths in project-config. Helpful migration notice when 0
chapters found.

LOW: Fix ensureFolder to check TFolder not just any abstract file.
Use function callbacks in replace() to prevent regex special chars.
Fix multi-line annotation stripping in word count.

302 tests passing.
2026-04-16 13:31:52 -05:00
james.maes b0369c0bdc feat: settings header, section spacing, governance files, dep updates
Settings: Add full header with name/description/links (docs, getting
started, bugs, GitHub). Add CSS for collapsible section spacing/borders.
Default useSameModelForAll to false (show all three tiers).

Governance: SECURITY.md, CONTRIBUTING.md, CHANGELOG.md, CircleCI
config, PR template, issue templates (bug report, feature request).

Deps: Update esbuild to 0.28.0, vitest to 4.1.4 (fixes both
Dependabot alerts, 0 vulnerabilities).
2026-04-16 13:30:19 -05:00
james.maes 91518ea936 fix: address all 19 third-audit findings
CRITICAL: Prevent auto-save cascade by tracking new version file paths
in processingFiles set. Make thinking mode conditional on heavy tier
only (not light/standard).

HIGH: Skip overlapping annotations instead of garbling merge. Fix
first-run notice (remove always-true ollamaEndpoint check). Normalize
trailing slashes on path settings. Dynamic draftsFolder depth for book
inference. Add outer catch for vault.read errors.

MEDIUM: Fix PipelineProvider type cast. Wire token multiplier from
provider to context assembly. Remove dead detectCharacters call. Wire
commitMessageFormat setting. Wire verboseLogging setting. Fix HTML
comment parsing in project-config.

LOW: Import ANTHROPIC_MODELS in settings instead of duplicating. Fix
broken doc links. Add project-config tests.

302 tests passing across 15 test files.
2026-04-16 13:10:20 -05:00
james.maes ad31277025 fix: address all 21 second-audit findings
CRITICAL: Wire processingFiles guard in processChapter (re-entrancy).
Implement PENNY.md per-project config with directory walk and parsing.
Remove dead buildRouteConfig from commands.ts. Fix plotOutlinesFolder
to resolve book-specific outline files.

HIGH: Increase maxTokens to 16000 (configurable). Fix auto-save hook
to pass actual file instead of getActiveFile. Add .state.json creation
to migration and new chapter commands.

MEDIUM: Merge overlapping section scopes in assembler. Remove no-op
lastIndex on non-global regex. Wrap JSON.parse in provider parseResponse.

LOW: Fix release workflow notes template.

New: src/project-config.ts (PENNY.md per-project config parser).
287 tests passing, zero type errors.
2026-04-16 12:38:23 -05:00
james.maes 9369bc89cc fix: address all 23 audit findings
CRITICAL: Wire processChapter() pipeline (parse -> context -> draft ->
assemble -> version -> review -> log). Fix HTTP error handling in both
providers (check status, throw on non-2xx). Fix thinking mode (only for
opus/sonnet, not haiku). Add git safety checks (FileSystemAdapter, git
installed, is repo).

HIGH: Add migration checksum verification. Extract globMatch to utils.ts.
Fix regex state management (local instances). Fix requireProvider to
check all routes. Fix annotation guide claims.

MEDIUM: Quote YAML-unsafe strings. Handle CRLF in frontmatter. Filter
non-objects in readState. Fix save hook debounce. Remove countAnnotations
duplicate.

LOW: Remove unused registry singleton. Fix deprecated getLeaf API.
Update manifest description. Fix README links and screenshot. Fix prompt
duplication (task details only in user prompt).

New: pipeline.ts (pure logic), utils.ts, integration.test.ts,
utils.test.ts. 287 tests passing across 14 test files.
2026-04-16 12:10:50 -05:00
james.maes d52ce01d92 fix(settings): make voice rule textareas full-width below label 2026-04-16 11:43:50 -05:00
james.maes 9ca2192ed0 feat: LLM provider abstraction - service interface + model router
Add provider layer (src/providers/) with LLMService interface,
complexity-based model router (light/standard/heavy tiers),
provider registry, Anthropic and Ollama implementations.

Update settings UI with provider config and model routing.
Update drafter, context, commands for multi-provider support.
Migrate settings from old apiKey/model to new per-provider format.
81 new provider tests (244 total, all passing).
2026-04-16 11:40:24 -05:00
james.maes f35793f00e fix(manifest): add PENNY initialism to description 2026-04-16 10:23:34 -05:00
51 changed files with 6426 additions and 1490 deletions

No files matched your search

+31
View File
@@ -0,0 +1,31 @@
# Use the latest 2.1 version of CircleCI pipeline process engine.
# See: https://circleci.com/docs/reference/configuration-reference
version: 2.1
# Define a job to be invoked later in a workflow.
# See: https://circleci.com/docs/guides/orchestrate/jobs-steps/#jobs-overview & https://circleci.com/docs/reference/configuration-reference/#jobs
jobs:
say-hello:
# Specify the execution environment. You can specify an image from Docker Hub or use one of our convenience images from CircleCI's Developer Hub.
# See: https://circleci.com/docs/guides/execution-managed/executor-intro/ & https://circleci.com/docs/reference/configuration-reference/#executor-job
docker:
# Specify the version you desire here
# See: https://circleci.com/developer/images/image/cimg/base
- image: cimg/base:current
# Add steps to the job
# See: https://circleci.com/docs/guides/orchestrate/jobs-steps/#steps-overview & https://circleci.com/docs/reference/configuration-reference/#steps
steps:
# Checkout the code as the first step.
- checkout
- run:
name: "Say hello"
command: "echo Hello, World!"
# Orchestrate jobs using workflows
# See: https://circleci.com/docs/guides/orchestrate/workflows/ & https://circleci.com/docs/reference/configuration-reference/#workflows
workflows:
say-hello-workflow: # This is the name of the workflow, feel free to change it to better match your workflow.
# Inside the workflow, you define the jobs you want to run.
jobs:
- say-hello
+26
View File
@@ -0,0 +1,26 @@
---
name: Bug Report
about: Report a bug in PENNY
labels: bug
---
## Description
<!-- What happened? -->
## Steps to Reproduce
1.
2.
3.
## Expected Behavior
<!-- What should have happened? -->
## Actual Behavior
<!-- What actually happened? -->
## Environment
- PENNY version:
- Obsidian version:
- OS:
- LLM Provider: Anthropic / Ollama / Both
- Model:
+17
View File
@@ -0,0 +1,17 @@
---
name: Feature Request
about: Suggest an improvement to PENNY
labels: enhancement
---
## Feature Description
<!-- What would you like PENNY to do? -->
## Use Case
<!-- Why do you need this? What problem does it solve? -->
## Proposed Solution
<!-- How should it work? -->
## Alternatives Considered
<!-- Any other approaches you considered? -->
+19
View File
@@ -0,0 +1,19 @@
## Description
<!-- What does this PR do? -->
## Type of Change
- [ ] Bug fix
- [ ] New feature
- [ ] Documentation
- [ ] Refactoring
- [ ] Other: ___
## Testing
- [ ] All existing tests pass (`npm test`)
- [ ] New tests added for new functionality
- [ ] Tested manually in Obsidian
## Checklist
- [ ] Code follows project conventions (TypeScript strict, no `any`)
- [ ] No Obsidian API imports in pure-logic modules
- [ ] Documentation updated if needed
+6 -1
View File
@@ -32,5 +32,10 @@ jobs:
run: |
gh release create "$TAG_NAME" \
--title "v$TAG_NAME" \
--generate-notes \
--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
+20
View File
@@ -0,0 +1,20 @@
# Changelog
All notable changes to PENNY will be documented in this file.
## [0.1.0] - 2026-04-16
### Added
- Core annotation processing pipeline (REWRITE, EXPAND, CUT, TONE, DIALOG, PLOT, PACING, CHARACTER)
- Multi-provider support (Anthropic Claude API + Ollama local models)
- Complexity-based model routing (light/standard/heavy tiers)
- Chapter versioning system with idempotency
- Voice compliance checking
- Project scaffolding wizard
- Per-project configuration via PENNY.md
- Git commit/push integration
- Review note generation
- Activity logging (JSONL)
- Settings UI with collapsible sections
- 9 documentation guides
- 302 unit tests across 15 test files
+207
View File
@@ -0,0 +1,207 @@
# Contributing to PENNY
Thank you for your interest in contributing to PENNY. This guide covers everything you need to get started.
## Table of Contents
- [Development Setup](#development-setup)
- [Architecture Overview](#architecture-overview)
- [Branch Model](#branch-model)
- [Making Changes](#making-changes)
- [Code Style](#code-style)
- [Testing](#testing)
- [Commit Messages](#commit-messages)
- [Pull Request Process](#pull-request-process)
- [Branch Protection Setup (Maintainers)](#branch-protection-setup-maintainers)
## Development Setup
### Prerequisites
- Node.js 20 or later
- npm
- An Obsidian vault for manual testing
- Git
### Getting Started
```bash
# Fork the repository on GitHub, then:
git clone https://github.com/<your-username>/obsidian-penny.git
cd obsidian-penny
npm install
# Start the development build (watches for changes):
npm run dev
# Run the test suite:
npm test
# Run a production build:
npm run build
```
### Testing in Obsidian
1. Build the plugin (`npm run dev` for watch mode, or `npm run build` for a one-off build).
2. Copy or symlink `main.js`, `manifest.json`, and `styles.css` into your vault's `.obsidian/plugins/penny/` directory.
3. Reload Obsidian (Ctrl/Cmd + R) and enable PENNY in Settings > Community Plugins.
## Architecture Overview
PENNY separates pure logic from the Obsidian runtime shell:
```
src/
core/ Pure-logic modules (no Obsidian imports)
annotations/ Annotation parsing, classification, scope detection
versioning/ Chapter version management, idempotency
routing/ Complexity scoring, model tier assignment
voice/ Voice compliance checking
providers/ LLM provider abstraction (Anthropic, Ollama)
config/ Configuration loading, PENNY.md parsing
git/ Git operations (commit message generation, staging)
shell/ Obsidian-specific code
plugin.ts Main plugin class, command registration
settings.ts Settings tab UI
commands/ Command implementations that bridge core <-> Obsidian
```
**Key principle:** Modules under `src/core/` must never import from `obsidian`. They receive all Obsidian-specific data (file contents, paths, settings) through function parameters. This keeps the core testable without mocking Obsidian APIs.
**Provider abstraction:** All LLM calls go through a provider interface. Adding a new provider means implementing the interface and registering it -- no changes to annotation processing or routing logic.
## Branch Model
- **`main`** -- Release branch. Protected. Only receives merges from `develop` via pull request. Every merge to `main` is a release.
- **`develop`** -- Integration branch. This is the default branch. All feature and fix branches merge here first.
- **Feature branches** -- Branch from `develop`, named `feature/<short-description>` or `fix/<short-description>`.
```
main -------- release tags
\
develop -- integration
\
feature/my-feature
```
## Making Changes
1. Fork the repository.
2. Create a branch from `develop`:
```bash
git checkout develop
git pull origin develop
git checkout -b feature/my-feature
```
3. Make your changes.
4. Write or update tests.
5. Ensure all tests pass: `npm test`
6. Ensure the build succeeds: `npm run build`
7. Commit using conventional commit format (see below).
8. Push to your fork and open a pull request against `develop`.
## Code Style
- **TypeScript strict mode.** The `tsconfig.json` enforces strict checks. Do not weaken them.
- **No `any`.** Use proper types. If you need a flexible type, use `unknown` and narrow it.
- **Pure functions where possible.** Functions in `src/core/` should take inputs and return outputs without side effects. Side effects belong in `src/shell/`.
- **No Obsidian imports in `src/core/`.** This is a hard boundary. If you need Obsidian functionality in a core module, pass it as a parameter or callback.
- **Explicit return types** on exported functions.
- **No default exports.** Use named exports.
## Testing
```bash
# Run all tests
npm test
# Run tests in watch mode (re-runs on file changes)
npm run test:watch
# Run tests with coverage report
npm run test:coverage
```
### Testing Requirements
- All existing tests must pass before submitting a PR.
- New features must include tests.
- Bug fixes should include a regression test.
- Tests live in `test/` and mirror the `src/` directory structure.
- Tests for `src/core/` modules should not require mocking Obsidian APIs (that is the whole point of the architecture split).
## Commit Messages
Use [Conventional Commits](https://www.conventionalcommits.org/):
```
<type>(<scope>): <short description>
<optional body>
<optional footer>
```
### Types
| Type | When to use |
|------------|--------------------------------------------------|
| `feat` | New feature |
| `fix` | Bug fix |
| `docs` | Documentation only |
| `style` | Formatting, no logic change |
| `refactor` | Code restructuring, no feature or fix |
| `test` | Adding or updating tests |
| `chore` | Build, CI, tooling, dependency updates |
### Examples
```
feat(annotations): add RESEARCH annotation type
fix(versioning): handle chapters with no existing versions
docs: update annotation guide with RESEARCH examples
test(routing): add coverage for edge-case complexity scores
chore(ci): add CircleCI type-check step
```
## Pull Request Process
1. Open your PR against `develop` (not `main`).
2. Fill out the PR template completely.
3. Ensure CI passes (type check, tests, build).
4. Request review from a maintainer.
5. Address review feedback. Push new commits (do not force-push during review).
6. Once approved, a maintainer will merge.
### PR Checklist
- [ ] Branch is based on `develop`
- [ ] All tests pass (`npm test`)
- [ ] Build succeeds (`npm run build`)
- [ ] No `any` types introduced
- [ ] No Obsidian imports in `src/core/`
- [ ] New features have tests
- [ ] Documentation updated if needed
- [ ] Commit messages follow conventional format
## Branch Protection Setup (Maintainers)
The following commands configure branch protection and the default branch. These require admin access to the repository and should be run once during initial setup:
```bash
# Protect the main branch: require status checks, enforce on admins, require 1 approving review
gh api repos/KofTwentyTwo/obsidian-penny/branches/main/protection \
-X PUT \
-f required_status_checks='{"strict":true,"contexts":["test"]}' \
-f enforce_admins=true \
-f required_pull_request_reviews='{"required_approving_review_count":1}' \
-f restrictions=null
# Set develop as the default branch
gh api repos/KofTwentyTwo/obsidian-penny -X PATCH -f default_branch=develop
```
## Questions?
Open a [discussion](https://github.com/KofTwentyTwo/obsidian-penny/discussions) or reach out via an issue. We are happy to help you get oriented.
+14 -8
View File
@@ -2,11 +2,13 @@
Your AI co-author for Obsidian. PENNY processes editorial annotations in your novel chapters, revises prose while enforcing your voice rules, and manages versioned drafts -- all without leaving Obsidian. It uses Claude (via the Anthropic API) to rewrite, expand, cut, adjust tone, fix dialogue, and more, guided by your style guide and voice tests.
![Screenshot placeholder -- TODO](docs/images/screenshot-placeholder.png)
<!-- Screenshots coming soon -->
## What PENNY Does
- **Process editorial annotations** -- Drop `%% REWRITE: more tension here %%` into your chapter and PENNY rewrites that passage for you.
- **Multi-provider LLM support** -- Use Anthropic Claude (cloud) for maximum quality, Ollama (local) for privacy and zero cost, or both at once with model routing.
- **Model routing by complexity** -- Simple tasks (CUT, PACING) can use a fast/cheap model while complex tasks (REWRITE, DIALOG) get the strongest model. Configure per-tier or use one model for everything.
- **Enforce voice consistency** -- PENNY reads your style guide and voice tests before every revision so the output sounds like your book, not a chatbot.
- **Manage chapter versions** -- Every revision creates a new numbered version (`ch-05.v1.md`, `ch-05.v2.md`, ...) with full history preserved.
- **Generate review notes** -- After processing, PENNY writes a review file summarizing what changed, word count deltas, and voice compliance flags.
@@ -16,10 +18,11 @@ Your AI co-author for Obsidian. PENNY processes editorial annotations in your no
## Quick Start
1. Install PENNY from Obsidian's Community Plugins browser (Settings > Community plugins > Browse > search "PENNY").
2. Set your Anthropic API key in Settings > PENNY > API Settings.
3. Open a chapter file and add an annotation anywhere in the prose: `%% REWRITE: more tension here %%`
4. Open the command palette (Ctrl/Cmd + P) and run **PENNY: Process this chapter**.
5. PENNY creates a new version of the chapter in the same folder. Open it and review the changes.
2. Configure a provider in Settings > PENNY > Providers: enter your Anthropic API key, or point PENNY at a running Ollama instance, or both.
3. Choose your model routing in Settings > PENNY > Model Routing (or leave the defaults).
4. Open a chapter file and add an annotation anywhere in the prose: `%% REWRITE: more tension here %%`
5. Open the command palette (Ctrl/Cmd + P) and run **PENNY: Process this chapter**.
6. PENNY creates a new version of the chapter in the same folder. Open it and review the changes.
## Documentation
@@ -38,7 +41,10 @@ Your AI co-author for Obsidian. PENNY processes editorial annotations in your no
## Requirements
- **Obsidian 1.5.0** or later
- **Anthropic API key** -- get one at [console.anthropic.com](https://console.anthropic.com)
- **At least one LLM provider:**
- **Anthropic API key** -- get one at [console.anthropic.com](https://console.anthropic.com) (cloud, paid per token)
- **Ollama** -- install from [ollama.com](https://ollama.com) (local, free, runs on your machine)
- Or both, for hybrid routing (e.g. local models for simple edits, Claude for complex work)
- **Desktop only** -- PENNY uses Node.js for git operations and is not available on Obsidian Mobile
## Installation
@@ -104,8 +110,8 @@ PENNY is configured through the Obsidian settings tab (Settings > PENNY) and opt
Key settings:
- **API Key** -- Your Anthropic API key.
- **Model** -- Which Claude model to use (default: `claude-opus-4-6`).
- **Providers** -- Configure Anthropic (API key) and/or Ollama (endpoint URL). Test connection buttons verify setup.
- **Model routing** -- Assign a provider + model to each complexity tier (Light, Standard, Heavy), or use one model for everything.
- **Project structure paths** -- Tell PENNY where your drafts, characters, style guide, outlines, and wiki live.
- **Voice rules** -- Custom rules injected into every revision prompt.
- **Behavior** -- Auto-process on save, verbose logging, file patterns.
+51
View File
@@ -0,0 +1,51 @@
# Security Policy
## Supported Versions
Only the latest released version of PENNY receives security updates.
| Version | Supported |
|---------|--------------------|
| Latest | Yes |
| Older | No |
## Reporting a Vulnerability
**Do not open a public GitHub issue for security vulnerabilities.**
Instead, please use one of the following methods:
1. **GitHub Security Advisories (preferred):** Go to the [Security tab](https://github.com/KofTwentyTwo/obsidian-penny/security/advisories) and click "Report a vulnerability." This creates a private channel between you and the maintainers.
2. **Email:** Send details to **james@koftwentytwo.com** with the subject line `[PENNY Security]`. Include:
- A description of the vulnerability
- Steps to reproduce
- The potential impact
- Any suggested fix (optional)
## Response Timeline
- **Acknowledgment:** Within 48 hours of receiving the report.
- **Initial assessment:** Within 7 days.
- **Fix or mitigation:** Within 30 days for confirmed vulnerabilities, with a public advisory published alongside the patch release.
If you have not received a response within 48 hours, please follow up.
## API Key and Credential Handling
PENNY stores API keys (Anthropic, Ollama endpoint URLs) in Obsidian's local plugin data store (`data.json` inside `.obsidian/plugins/penny/`). These credentials:
- Are stored only on the user's local machine.
- Are never transmitted to any server other than the LLM provider the user has explicitly configured (Anthropic API or a user-specified Ollama endpoint).
- Are never logged, telemetered, or sent to PENNY's maintainers or any third party.
- Are never included in git commits (the `data.json` file is excluded by `.gitignore`).
Users are responsible for keeping their API keys confidential and rotating them if they suspect compromise.
## Scope
This policy covers the PENNY Obsidian plugin source code and its official releases. It does not cover:
- The Anthropic API or Ollama software (report issues to those projects directly).
- User-created configuration files or vault content.
- Third-party forks or modified builds.
+2 -2
View File
@@ -267,7 +267,7 @@ PENNY revises everything from "She entered..." to the next `##` heading.
### Multiple annotations on the same passage
When multiple annotations target the same text, PENNY combines all instructions and processes them together in a single revision.
**Planned for future version.** Currently, each annotation is processed independently. If multiple annotations target the same passage, each one generates a separate revision pass applied in sequence (bottom to top).
```markdown
"I suppose we might consider an alternative approach," she said thoughtfully.
@@ -277,7 +277,7 @@ When multiple annotations target the same text, PENNY combines all instructions
%% TONE: Match the voice tests. %%
```
PENNY combines all three instructions and produces one revision that addresses dialog, character voice, and tone together.
Each annotation above is processed as a separate revision. The DIALOG annotation is applied first, then CHARACTER, then TONE. For best results when multiple concerns apply to one passage, combine them into a single annotation with all instructions.
## Tips for Writing Good Instructions
+34 -2
View File
@@ -1,6 +1,6 @@
# API Costs
PENNY uses the Anthropic API to process annotations. You pay per token. This guide explains how costs work and how to manage them.
PENNY can use multiple LLM providers. Anthropic Claude charges per token, while Ollama runs locally at no API cost. This guide explains how costs work and how to manage them.
## How PENNY Uses the API
@@ -109,15 +109,46 @@ These are rough estimates. Actual costs depend on your chapter lengths, context
**Note:** Anthropic pricing changes over time. Check [anthropic.com/pricing](https://www.anthropic.com/pricing) for current rates.
## Ollama (Local Models) -- Free
Ollama runs models locally on your machine. There is no per-token cost. The only costs are:
- **Electricity and hardware** -- running large models requires a capable GPU. Smaller models (7B parameters) run well on most modern machines.
- **Quality trade-off** -- local models are generally less capable than Claude Opus for nuanced prose work, but excellent for simple edits.
### When to use Ollama
- **CUT annotations** -- condensing is mechanical. A local model handles it well.
- **PACING annotations** -- restructuring paragraphs requires less voice nuance.
- **Bulk first passes** -- process all annotations with a fast local model, then re-annotate the ones that need more finesse and process those with Claude.
- **Privacy-sensitive projects** -- nothing leaves your machine.
### Hybrid routing for cost reduction
With model routing, you can send different annotation types to different providers in the same chapter pass:
| Tier | Provider | Cost |
|------|----------|------|
| Light (CUT, PACING) | Ollama/llama3.2 | Free |
| Standard (TONE, EXPAND, PLOT) | Anthropic/Sonnet | ~$0.04/annotation |
| Heavy (REWRITE, DIALOG, CHARACTER) | Anthropic/Opus | ~$0.07/annotation |
For a chapter with 2 CUT, 2 EXPAND, and 1 REWRITE annotation, the hybrid cost is roughly $0.15 vs. $0.35 for all-Opus. Over a full novel revision pass, this adds up.
## Tips for Reducing Costs
### Use model routing
PENNY's model routing is the most effective cost lever. Light tasks (CUT, PACING) rarely need a top-tier model. Set them to Haiku or a local Ollama model and reserve Opus for REWRITE, DIALOG, and CHARACTER annotations where voice precision matters.
### Use the right model for the job
- **Opus** for voice-critical scenes, complex emotional beats, and chapters where precision matters most.
- **Sonnet** for routine revisions, dialogue fixes, and general prose tightening. Good balance of quality and cost.
- **Haiku** for bulk first-pass processing, simple cuts, and when you plan to do a follow-up pass with a better model.
- **Ollama** for free local processing of simple edits and privacy-sensitive work.
Switching models per chapter is easy -- change the model in settings before processing.
Model routing handles this automatically once configured -- you do not need to manually switch models.
### Write fewer, better annotations
@@ -168,6 +199,7 @@ PENNY writes a JSONL log file to your activity log folder (default: `.penny-log/
"wordCountAfter": 3450,
"tags": ["REWRITE", "EXPAND"],
"durationMs": 45000,
"provider": "anthropic",
"model": "claude-opus-4-6"
}
```
+67 -10
View File
@@ -2,34 +2,89 @@
PENNY is configured through the Obsidian settings tab and optionally through a `PENNY.md` file for per-project overrides. This guide explains every setting.
## API Settings
## Provider Settings
### API Key
PENNY supports multiple LLM providers. Configure at least one.
### Anthropic API Key
| Field | Value |
|-------|-------|
| Type | Password text |
| Default | (empty) |
| Required | Yes |
| Required | Only if using Anthropic models |
Your Anthropic API key. Get one at [console.anthropic.com](https://console.anthropic.com). The key is stored locally in `.obsidian/plugins/penny/data.json` and is sent only to Anthropic's API endpoint (`https://api.anthropic.com/v1/messages`).
### Model
### Test Anthropic Connection
Button that sends a minimal request to the Anthropic API to verify the key is valid. Reports success or the specific error.
### Ollama Endpoint
| Field | Value |
|-------|-------|
| Type | Dropdown |
| Default | `claude-opus-4-6` |
| Type | Text |
| Default | `http://localhost:11434` |
Which Claude model to use for revisions. Available options and recommendations:
URL of the Ollama server. Change only if Ollama runs on a different host or port (e.g. a remote server or Docker container).
### Ollama API Key (Optional)
| Field | Value |
|-------|-------|
| Type | Password text |
| Default | (empty) |
Only needed if your Ollama instance requires authentication (e.g. a remote hosted instance behind a proxy). Leave blank for local Ollama.
### Test Ollama Connection
Button that hits Ollama's `/api/tags` endpoint to verify connectivity and list available local models.
## Model Routing
Model routing lets PENNY use different models for different types of work. Simple tasks can use a fast/cheap model while complex tasks get the strongest model.
### Use Same Model for All Tiers
| Field | Value |
|-------|-------|
| Type | Toggle |
| Default | On |
When on, all annotations use a single provider and model (configured in one row). When off, three rows appear -- one per complexity tier.
### Complexity Tiers
Annotation tags are grouped by complexity:
| Tier | Tags | Recommended Use |
|------|------|-----------------|
| **Light** | CUT, PACING | Fast/cheap models. Simple mechanical edits. |
| **Standard** | TONE, EXPAND, PLOT | Balanced quality and cost. General-purpose revisions. |
| **Heavy** | REWRITE, DIALOG, CHARACTER | Best model available. Voice-critical, nuanced work. |
Each tier has a **provider dropdown** and a **model selector**:
- For Anthropic, the model is a dropdown with available Claude models.
- For Ollama, the model is a text field where you type the model name (e.g. `llama3.2`, `mistral`).
### Example Configurations
| Setup | Light | Standard | Heavy |
|-------|-------|----------|-------|
| All Anthropic (default) | Haiku | Sonnet | Opus |
| All local | llama3.2 | mistral | deepseek-coder |
| Hybrid (cost-optimized) | Ollama/llama3.2 | Anthropic/Sonnet | Anthropic/Opus |
| Single model | -- | Sonnet (all tiers) | -- |
### Anthropic Models
| Model | Best For | Trade-off |
|-------|----------|-----------|
| `claude-opus-4-6` | Literary prose, voice matching, complex scenes | Highest quality, highest cost |
| `claude-sonnet-4-6` | Routine revisions, dialogue fixes, cuts | Good quality, moderate cost |
| `claude-haiku-3-5` | Quick passes, simple rewrites, bulk processing | Fastest, lowest cost, less nuanced |
**Recommendation:** Use Opus for chapters that matter most (key scenes, voice-critical passages, complex emotional beats). Use Sonnet for routine work. Use Haiku for bulk first-pass processing when you plan to do a second pass with a better model.
| `claude-haiku-4-5` | Quick passes, simple rewrites, bulk processing | Fastest, lowest cost, less nuanced |
### Context Budget
@@ -42,6 +97,8 @@ Maximum tokens for context assembly. PENNY loads your chapter, style guide, voic
For most projects, the default of 800,000 tokens is more than sufficient. Lower this if you want to reduce API costs or speed up processing.
**Note on token estimation:** Different providers use different tokenizers. PENNY uses a words-to-tokens multiplier (1.33 for Anthropic, 1.0 for Ollama) for budget estimation. The context budget applies regardless of which provider handles the annotation.
## Project Structure
All paths are relative to the vault root. These tell PENNY where to find your project's files. Leave a path blank to disable that context source -- PENNY will skip it without error.
+43 -6
View File
@@ -24,24 +24,61 @@ Use this if PENNY is not yet in the community browser or you want to install fro
6. Restart Obsidian (or reload without cache: Ctrl/Cmd + Shift + R on desktop).
7. Go to **Settings > Community plugins**, find PENNY in the list, and toggle it on.
## 2. Get an Anthropic API Key
## 2. Configure an LLM Provider
PENNY calls Claude through the Anthropic API. You need an API key.
PENNY supports multiple LLM providers. You need at least one.
### Option A: Anthropic (Claude API -- Cloud)
Best for: highest-quality prose revision, voice matching, complex scenes.
1. Go to [console.anthropic.com](https://console.anthropic.com).
2. Create an account or sign in.
3. Navigate to **API Keys** and click **Create Key**.
4. Copy the key. You will not be able to see it again.
5. In Obsidian, go to **Settings > PENNY > Providers**.
6. Paste the key into the **Anthropic API key** field.
7. Click **Test Anthropic connection** to verify.
**Pricing note:** Anthropic charges per token. PENNY makes one API call per annotation (not per chapter). A typical revision pass on a chapter with 5 annotations costs roughly $0.50-$2.00 depending on the model and context size. See [API Costs](api-costs.md) for detailed estimates.
**Which plan?** Any Anthropic API plan works. There is no minimum tier requirement. You load credits and pay for what you use.
## 3. Enter Your API Key
### Option B: Ollama (Local Models -- Free)
1. In Obsidian, go to **Settings > PENNY**.
2. In the **API Settings** section, paste your API key into the **API Key** field.
3. The key is stored locally in your vault's plugin data (`.obsidian/plugins/penny/data.json`). It is never sent anywhere except Anthropic's API endpoint.
Best for: privacy, zero cost, offline work, simple edits.
1. Install Ollama from [ollama.com](https://ollama.com). It runs on macOS, Linux, and Windows.
2. Pull a model. Open a terminal and run:
```bash
ollama pull llama3.2
```
Other good choices: `mistral`, `deepseek-coder`, `gemma2`. See [Ollama's model library](https://ollama.com/library) for the full list.
3. Ollama starts automatically after install. Verify it is running:
```bash
ollama list
```
4. In Obsidian, go to **Settings > PENNY > Providers**.
5. The **Ollama endpoint** defaults to `http://localhost:11434`. Change this only if Ollama runs on a different host or port.
6. Click **Test Ollama connection** to verify. PENNY will list your locally available models.
**No API key needed** for local Ollama. If you use a remote/hosted Ollama instance that requires authentication, enter the key in the optional **Ollama API key** field.
### Option C: Both (Hybrid Routing)
You can configure both providers and use model routing to send different tasks to different models. For example:
- Light tasks (CUT, PACING) go to a fast local Ollama model (free)
- Heavy tasks (REWRITE, DIALOG, CHARACTER) go to Claude Opus (best quality)
Set this up in **Settings > PENNY > Model Routing** after configuring both providers. See [Configuration](configuration.md) for details.
## 3. Choose Your Model
1. In Obsidian, go to **Settings > PENNY > Model Routing**.
2. By default, **Use same model for all tiers** is on. Pick a provider and model from the dropdowns.
3. If you want different models for different annotation types, turn the toggle off and configure each tier separately.
API keys and endpoints are stored locally in your vault's plugin data (`.obsidian/plugins/penny/data.json`). They are never sent anywhere except the configured provider endpoints.
## 4. Set Up Your Project
+85 -7
View File
@@ -2,16 +2,24 @@
Common issues and how to fix them.
## "No API key configured"
## "No LLM provider configured"
**Symptom:** PENNY shows a notice saying no API key is set when you try to process a chapter.
**Symptom:** PENNY shows a notice saying no LLM provider is configured when you try to process a chapter.
**Fix:**
1. Go to Settings > PENNY > API Settings.
2. Paste your Anthropic API key into the API Key field.
**Fix:** You need at least one provider configured.
**For Anthropic:**
1. Go to Settings > PENNY > Providers.
2. Paste your Anthropic API key into the **Anthropic API key** field.
3. The key starts with `sk-ant-`. If your key looks different, it may be from a different provider.
4. Get a key at [console.anthropic.com](https://console.anthropic.com) if you do not have one.
**For Ollama:**
1. Install Ollama from [ollama.com](https://ollama.com).
2. Pull a model: `ollama pull llama3.2`
3. Go to Settings > PENNY > Providers. The Ollama endpoint defaults to `http://localhost:11434`.
4. Click **Test Ollama connection** to verify.
## Authentication Error (401)
**Symptom:** PENNY shows "API Error: 401 Unauthorized" when processing.
@@ -19,13 +27,15 @@ Common issues and how to fix them.
**Causes:**
- The API key is invalid, expired, or revoked.
- The key was copied with leading/trailing whitespace.
- A provider mismatch: the model routing points to Anthropic but no Anthropic API key is set.
**Fix:**
1. Go to Settings > PENNY > API Settings.
2. Clear the API Key field completely.
1. Go to Settings > PENNY > Providers.
2. Clear the Anthropic API Key field completely.
3. Go to [console.anthropic.com](https://console.anthropic.com) > API Keys.
4. Verify your key is active. If in doubt, create a new one.
5. Copy the new key carefully (no extra spaces) and paste it into the settings field.
6. Click **Test Anthropic connection** to verify before processing again.
## "No annotations found"
@@ -204,3 +214,71 @@ git remote add origin https://github.com/your-username/your-vault.git
2. Fix the underlying issue (API key, rate limit, network, etc.).
3. The original annotation is preserved. Run **PENNY: Process this chapter** again and PENNY will retry the failed annotations.
4. Delete the `AGENT-ERROR` markers after successful reprocessing -- they are informational only.
## Ollama Connection Issues
### "Ollama connection failed"
**Symptom:** The Test Ollama Connection button fails, or processing fails when routing to Ollama.
**Common causes and fixes:**
1. **Ollama is not running.** Start it:
```bash
ollama serve
```
On macOS, Ollama usually runs as a background service after install. Check your menu bar for the Ollama icon.
2. **Wrong endpoint.** The default is `http://localhost:11434`. If you changed Ollama's port or are running it on a remote machine, update the endpoint in Settings > PENNY > Providers.
3. **Firewall blocking the connection.** Obsidian makes HTTP requests to the Ollama endpoint. If a firewall or security tool is blocking localhost connections, whitelist Obsidian or the Ollama port.
4. **Docker networking.** If Ollama runs in Docker, `localhost` inside Obsidian points to your host machine, not the container. Use the container's exposed port or Docker network address.
### "Model not found" from Ollama
**Symptom:** Ollama returns an error about the model not being available.
**Fix:**
1. Check which models you have installed:
```bash
ollama list
```
2. If the model in your routing settings is not listed, pull it:
```bash
ollama pull llama3.2
```
3. Verify the model name in Settings > PENNY > Model Routing matches exactly what Ollama reports (e.g. `llama3.2`, not `llama3` or `Llama3.2`).
### Ollama is slow
**Cause:** Local model inference speed depends on your hardware. Large models (70B+ parameters) are slow without a powerful GPU.
**Fix:**
- Use a smaller model for Light-tier tasks (7B or 8B parameter models are fast on most machines).
- Reserve Ollama for simple edits (CUT, PACING) and route complex work to Anthropic.
- If you have a GPU, make sure Ollama is using it (check Ollama docs for GPU configuration).
## Model Routing Misconfiguration
### "Model routing uses Anthropic but no API key is set"
**Symptom:** PENNY refuses to process and shows a notice about routing to a provider that is not configured.
**Fix:**
1. Go to Settings > PENNY > Model Routing.
2. Check which provider each tier points to.
3. Either configure the provider in Settings > PENNY > Providers, or change the routing to point to a provider you have configured.
### Wrong model for the job
**Symptom:** Output quality varies widely across annotations in the same chapter.
**Cause:** If model routing is on with different tiers, some annotations may be using a less capable model.
**Fix:**
1. Check the review note. Each annotation change lists which provider and model were used.
2. If a Light-tier model produced poor results on a CUT annotation, either:
- Reannotate with a more specific instruction, or
- Change the Light tier to a more capable model in Settings > PENNY > Model Routing.
3. If you want consistent quality across all annotations, turn on **Use same model for all tiers** and select your preferred model.
+1 -1
View File
@@ -146,7 +146,7 @@ Run **PENNY: Migrate chapters** from the command palette.
### Migration is safe:
- **Checksum verified** -- PENNY verifies the copy matches the original before deleting the flat file.
- **Content verified** -- After writing the versioned copy, PENNY reads it back and compares it to the original content. The original flat file is only deleted if the content matches exactly. If verification fails, the original file is preserved and an error is shown.
- **Idempotent** -- Chapters already in folder format are skipped. Running migration twice is safe.
- **Non-destructive** -- If anything fails mid-migration, already-migrated chapters remain valid and un-migrated chapters remain as flat files.
+1 -1
View File
@@ -3,7 +3,7 @@
"name": "PENNY - Prose Co-Author",
"version": "0.1.0",
"minAppVersion": "1.5.0",
"description": "AI-powered prose revision agent that processes editorial annotations and enforces voice consistency. Uses Claude API.",
"description": "PENNY (Prose Engine for Narrative, Notes, and Yarns) - AI-powered prose revision agent. Supports Claude API and Ollama local models. Processes editorial annotations and enforces voice consistency.",
"author": "James Maes",
"authorUrl": "https://github.com/KofTwentyTwo",
"isDesktopOnly": true
+939 -1022
View File
File diff suppressed because it is too large. Load diff
+2 -2
View File
@@ -26,10 +26,10 @@
"devDependencies": {
"@types/node": "^20.0.0",
"builtin-modules": "^3.3.0",
"esbuild": "^0.21.0",
"esbuild": "^0.28.0",
"obsidian": "latest",
"tslib": "2.6.2",
"typescript": "5.4.5",
"vitest": "^1.6.0"
"vitest": "^4.1.4"
}
}
+55 -15
View File
@@ -9,8 +9,8 @@
import type { AnnotatedSection } from "./types";
import { PASSTHROUGH_TAGS } from "./types";
/** Regex matching a single annotation comment: %% TAG: instruction %% */
const ANNOTATION_RE = /%%\s*([A-Z]+)\s*:\s*(.*?)\s*%%/g;
/** Non-global regex pattern for matching annotations. A new RegExp is created per use. */
const ANNOTATION_PATTERN = /%%\s*([A-Z]+)\s*:\s*(.*?)\s*%%/;
function wordCount(text: string): number {
const trimmed = text.trim();
@@ -22,6 +22,18 @@ function isPassthrough(tag: string): boolean {
return (PASSTHROUGH_TAGS as readonly string[]).includes(tag);
}
/** Info about an annotation that was skipped due to overlap. */
export interface SkippedOverlap {
tag: string;
line: number;
}
/** Result of assembleNewVersion, including assembled text and any skipped overlaps. */
export interface AssemblyResult {
content: string;
skippedOverlaps: SkippedOverlap[];
}
/**
* Assemble a new version of the chapter by applying revisions.
*
@@ -37,14 +49,47 @@ export function assembleNewVersion(
originalContent: string,
revisions: Array<{ annotation: AnnotatedSection; revisedText: string }>,
version: number = 1,
): string {
): AssemblyResult {
const lines = originalContent.split("\n");
// Sort revisions bottom-up (highest lineStart first) so that replacements
// don't shift line numbers for earlier entries.
const sorted = [...revisions].sort((a, b) => b.annotation.lineStart - a.annotation.lineStart);
for (const { annotation, revisedText } of sorted) {
// Handle overlapping ranges: keep the annotation with the larger scope (more
// lines) and skip the smaller one. Skipped annotations are recorded in a
// flags list so the review notes can report them.
const resolved: typeof sorted = [];
const skippedOverlaps: Array<{ tag: string; line: number }> = [];
for (const rev of sorted) {
if (resolved.length === 0) {
resolved.push(rev);
continue;
}
const prev = resolved[resolved.length - 1];
// Since sorted descending by lineStart, prev.lineStart >= rev.lineStart
// Same-line inline annotations are NOT overlaps -- they target different parts of the line
const bothInline = prev.annotation.scope === "inline" && rev.annotation.scope === "inline"
&& prev.annotation.lineStart === rev.annotation.lineStart;
// Overlap: prev.lineStart <= rev.lineEnd (prev starts before rev ends)
if (!bothInline && prev.annotation.lineStart <= rev.annotation.lineEnd) {
// Keep the annotation with the larger scope; skip the smaller one
const prevSpan = prev.annotation.lineEnd - prev.annotation.lineStart;
const revSpan = rev.annotation.lineEnd - rev.annotation.lineStart;
if (revSpan > prevSpan) {
// The new (larger) one replaces the previous (smaller) one
skippedOverlaps.push({ tag: prev.annotation.tag, line: prev.annotation.lineStart });
resolved[resolved.length - 1] = rev;
} else {
// Keep previous, skip current
skippedOverlaps.push({ tag: rev.annotation.tag, line: rev.annotation.lineStart });
}
} else {
resolved.push(rev);
}
}
for (const { annotation, revisedText } of resolved) {
// Skip passthrough tags -- they should never appear in revisions, but
// guard just in case.
if (isPassthrough(annotation.tag)) continue;
@@ -74,18 +119,16 @@ export function assembleNewVersion(
// Passthrough annotations (NOTE, RESEARCH) are kept.
const cleaned = lines.filter((line) => {
// Check if this line is purely an annotation (no other content).
const withoutAnnotations = line.replace(ANNOTATION_RE, "").trim();
const withoutAnnotations = line.replace(new RegExp(ANNOTATION_PATTERN.source, "g"), "").trim();
if (withoutAnnotations.length > 0) {
// Line has content beyond annotations. Strip only actionable annotations.
return true;
}
// Line is purely annotation(s). Keep it only if ALL annotations on it
// are passthrough.
ANNOTATION_RE.lastIndex = 0;
let m: RegExpExecArray | null;
let hasActionable = false;
let hasAny = false;
while ((m = ANNOTATION_RE.exec(line)) !== null) {
for (const m of line.matchAll(new RegExp(ANNOTATION_PATTERN.source, "g"))) {
hasAny = true;
if (!isPassthrough(m[1])) {
hasActionable = true;
@@ -100,24 +143,21 @@ export function assembleNewVersion(
// For lines that contain inline actionable annotations mixed with text,
// strip the annotation portion but keep the text.
const final = cleaned.map((line) => {
ANNOTATION_RE.lastIndex = 0;
if (!ANNOTATION_RE.test(line)) return line;
if (!ANNOTATION_PATTERN.test(line)) return line;
// Check if any annotation on this line is actionable.
ANNOTATION_RE.lastIndex = 0;
let hasActionable = false;
let m: RegExpExecArray | null;
while ((m = ANNOTATION_RE.exec(line)) !== null) {
for (const m of line.matchAll(new RegExp(ANNOTATION_PATTERN.source, "g"))) {
if (!isPassthrough(m[1])) hasActionable = true;
}
if (!hasActionable) return line;
// Strip only actionable annotations, keep passthrough.
return line.replace(/%%\s*([A-Z]+)\s*:\s*.*?\s*%%/g, (full, tag) => {
return line.replace(new RegExp(ANNOTATION_PATTERN.source, "g"), (full, tag) => {
if (isPassthrough(tag)) return full;
return "";
}).trimEnd();
});
return final.join("\n");
return { content: final.join("\n"), skippedOverlaps };
}
+588 -110
View File
@@ -5,10 +5,29 @@
* for scaffolding new novel projects.
*/
import { Modal, Notice, Setting, TFile, TFolder } from "obsidian";
import { FileSystemAdapter, Modal, Notice, Setting, TFile, TFolder } from "obsidian";
import { execFile } from "child_process";
import type PennyPlugin from "./main";
import type { ProjectInitOptions } from "./types";
import { parseAnnotations } from "./parser";
import { selectVoiceTestSection } from "./context";
import type { ContextFiles } from "./context";
import { parseFrontmatter, detectCharacters } from "./frontmatter";
import { getReviewFilePath } from "./reviewer";
import { getLogFilePath } from "./logger";
import { runPipeline } from "./pipeline";
import type { PipelineResult } from "./pipeline";
import { globMatch } from "./utils";
import { readVersion, nextVersion } from "./versioner";
import { generateStateJson } from "./migrate";
import { findProjectConfig } from "./project-config";
/**
* Normalize a settings path by stripping trailing slashes.
*/
function normalizePath(p: string): string {
return p.replace(/\/+$/, "");
}
/**
* Register all PENNY commands with the Obsidian command palette.
@@ -34,7 +53,7 @@ export function registerCommands(plugin: PennyPlugin): void {
if (!file || !isChapterFile(file, plugin)) return false;
if (checking) return true;
if (!requireApiKey(plugin)) return;
if (!requireProvider(plugin)) return;
processChapter(plugin, file);
},
});
@@ -44,7 +63,7 @@ export function registerCommands(plugin: PennyPlugin): void {
id: "penny:process-all",
name: "Process all chapters",
callback: async () => {
if (!requireApiKey(plugin)) return;
if (!requireProvider(plugin)) return;
const chapters = getAnnotatedChapters(plugin);
if (chapters.length === 0) {
@@ -53,10 +72,21 @@ export function registerCommands(plugin: PennyPlugin): void {
}
new Notice(`PENNY: Processing ${chapters.length} chapter(s)...`);
let processedCount = 0;
let totalAnnotations = 0;
let skippedCount = 0;
for (const file of chapters) {
await processChapter(plugin, file);
const result = await processChapter(plugin, file, { silent: true });
if (result) {
processedCount++;
totalAnnotations += result.annotationsProcessed;
} else {
skippedCount++;
}
}
new Notice(`PENNY: Finished processing ${chapters.length} chapter(s).`);
new Notice(
`PENNY: Processed ${processedCount} chapters (${totalAnnotations} annotations), skipped ${skippedCount} with no annotations.`,
);
},
});
@@ -147,15 +177,56 @@ export function registerCommands(plugin: PennyPlugin): void {
// ============================================================================
/**
* Check whether the API key is configured. Shows a notice if not.
* Check that every LLM provider referenced by any route tier is properly
* configured. Validates all three routes (light, standard, heavy) rather
* than just the standard route.
*
* Returns true only if every required provider is usable.
*/
function requireApiKey(plugin: PennyPlugin): boolean {
if (!plugin.settings.apiKey) {
new Notice(
"PENNY: No API key configured. Open Settings > PENNY to add your Anthropic API key."
);
return false;
export function requireProvider(plugin: PennyPlugin): boolean {
const s = plugin.settings;
const routes: Array<{ tier: string; provider: string }> = s.useSameModelForAll
? [{ tier: "standard", provider: s.routeStandard.provider }]
: [
{ tier: "light", provider: s.routeLight.provider },
{ tier: "standard", provider: s.routeStandard.provider },
{ tier: "heavy", provider: s.routeHeavy.provider },
];
// De-duplicate providers while keeping which tier(s) use them
const providerTiers = new Map<string, string[]>();
for (const r of routes) {
const existing = providerTiers.get(r.provider) ?? [];
existing.push(r.tier);
providerTiers.set(r.provider, existing);
}
for (const [provider, tiers] of providerTiers) {
const tierLabel = tiers.map((t) => `'${t}'`).join(", ");
if (provider === "anthropic") {
if (!s.anthropicApiKey) {
new Notice(
`PENNY: Route ${tierLabel} uses Anthropic but no API key is set. Open Settings > PENNY > Providers.`
);
return false;
}
} else if (provider === "ollama") {
if (!s.ollamaEndpoint) {
new Notice(
`PENNY: Route ${tierLabel} uses Ollama but no endpoint is configured. Open Settings > PENNY > Providers.`
);
return false;
}
} else {
new Notice(
`PENNY: Route ${tierLabel} uses unknown provider '${provider}'. Check Settings > PENNY > Model Routing.`
);
return false;
}
}
return true;
}
@@ -166,66 +237,12 @@ function isChapterFile(file: TFile, plugin: PennyPlugin): boolean {
return globMatch(plugin.settings.chapterFilePattern, file.name);
}
/**
* Simple glob matcher supporting * and ? wildcards.
* Uses iterative comparison to avoid ReDoS.
*/
function globMatch(pattern: string, text: string): boolean {
let pi = 0;
let ti = 0;
let starPi = -1;
let matchTi = -1;
while (ti < text.length) {
if (
pi < pattern.length &&
(pattern[pi] === text[ti] || pattern[pi] === "?")
) {
pi++;
ti++;
} else if (pi < pattern.length && pattern[pi] === "*") {
starPi = pi;
matchTi = ti;
pi++;
} else if (starPi !== -1) {
pi = starPi + 1;
matchTi++;
ti = matchTi;
} else {
return false;
}
}
while (pi < pattern.length && pattern[pi] === "*") {
pi++;
}
return pi === pattern.length;
}
/**
* Count annotations in file content.
*/
function countAnnotations(content: string): { actionable: number; passthrough: number } {
const ACTIONABLE_PATTERN =
/%%\s*(?:REWRITE|EXPAND|CUT|TONE|DIALOG|PLOT|PACING|CHARACTER)\s*:.*?%%/g;
const PASSTHROUGH_PATTERN = /%%\s*(?:NOTE|RESEARCH)\s*:.*?%%/g;
const actionableMatches = content.match(ACTIONABLE_PATTERN);
const passthroughMatches = content.match(PASSTHROUGH_PATTERN);
return {
actionable: actionableMatches ? actionableMatches.length : 0,
passthrough: passthroughMatches ? passthroughMatches.length : 0,
};
}
/**
* Find all chapter files that contain actionable annotations.
*/
function getAnnotatedChapters(plugin: PennyPlugin): TFile[] {
const allFiles = plugin.app.vault.getMarkdownFiles();
const draftsFolder = plugin.settings.draftsFolder;
const draftsFolder = normalizePath(plugin.settings.draftsFolder);
return allFiles.filter((f) => {
if (!f.path.startsWith(draftsFolder + "/")) return false;
@@ -241,31 +258,330 @@ function getAnnotatedChapters(plugin: PennyPlugin): TFile[] {
// ============================================================================
/**
* Process annotations in a single chapter file.
* This is a stub that will call into parser -> context -> drafter -> assembler
* once those modules are implemented.
* Safely read a vault file by path. Returns empty string if the file
* does not exist or cannot be read.
*/
async function processChapter(plugin: PennyPlugin, file: TFile): Promise<void> {
const content = await plugin.app.vault.read(file);
const counts = countAnnotations(content);
async function safeRead(plugin: PennyPlugin, path: string): Promise<string> {
if (!path) return "";
const file = plugin.app.vault.getAbstractFileByPath(path);
if (!file || !(file instanceof TFile)) return "";
try {
return await plugin.app.vault.read(file);
} catch {
return "";
}
}
if (counts.actionable === 0) {
new Notice(`PENNY: No actionable annotations in ${file.basename}.`);
return;
/**
* Read all markdown files in a folder, returning their contents as an array.
* Returns empty array if the folder does not exist.
*/
async function readFolderFiles(plugin: PennyPlugin, folderPath: string): Promise<string[]> {
if (!folderPath) return [];
const results: string[] = [];
const allFiles = plugin.app.vault.getMarkdownFiles();
const prefix = normalizePath(folderPath) + "/";
for (const f of allFiles) {
if (f.path.startsWith(prefix)) {
try {
const content = await plugin.app.vault.cachedRead(f);
if (content.trim()) results.push(content);
} catch {
// Skip unreadable files
}
}
}
return results;
}
/**
* Gather all context files from the vault based on settings paths.
*/
async function gatherContextFiles(
plugin: PennyPlugin,
chapterContent: string,
characters: string[],
bookId: string,
): Promise<ContextFiles> {
const s = plugin.settings;
// Normalize all folder/file paths to strip trailing slashes
const plotOutlinesFolder = normalizePath(s.plotOutlinesFolder);
const characterSheetsFolder = normalizePath(s.characterSheetsFolder);
const wikiFolder = normalizePath(s.wikiFolder);
// Read voice tests and pre-filter by detected characters
let voiceTests = await safeRead(plugin, s.voiceTests);
if (voiceTests && characters.length > 0) {
voiceTests = selectVoiceTestSection(voiceTests, characters);
}
const styleGuide = await safeRead(plugin, s.styleGuide);
// Read book-specific outline: {plotOutlinesFolder}/{bookId}/outline.md
// Fall back to reading plotOutlinesFolder as a file path (user may have set a direct file)
let outline = "";
if (plotOutlinesFolder) {
const bookOutlinePath = `${plotOutlinesFolder}/${bookId}/outline.md`;
outline = await safeRead(plugin, bookOutlinePath);
if (!outline) {
outline = await safeRead(plugin, plotOutlinesFolder);
}
}
const seriesBible = await safeRead(plugin, s.seriesBible);
const themes = await safeRead(plugin, s.themesFile);
const characterFiles = await readFolderFiles(plugin, characterSheetsFolder);
const wikiFiles = await readFolderFiles(plugin, wikiFolder);
return {
chapter: chapterContent,
voiceTests,
styleGuide,
outline,
seriesBible,
themes,
characters: characterFiles,
wiki: wikiFiles,
};
}
/**
* Extract the chapter folder path from a chapter file.
* e.g. "04-drafts/book-1/ch-05/ch-05.v2.md" -> "04-drafts/book-1/ch-05"
*/
function chapterFolderPath(file: TFile): string {
const parts = file.path.split("/");
parts.pop(); // remove filename
return parts.join("/");
}
/**
* Extract a chapter identifier from a file.
* e.g. "ch-05.v2" -> "ch-05"
*/
function chapterIdFromFile(file: TFile): string {
return file.basename.replace(/\.v\d+$/, "");
}
/**
* Extract book identifier from the file path.
* e.g. "04-drafts/book-1/ch-05/ch-05.v2.md" -> "book-1"
*/
function bookIdFromFile(file: TFile): string {
const parts = file.path.split("/");
for (const p of parts) {
if (/^book-\d+$/.test(p)) return p;
}
return "book-1";
}
// Pipeline logic lives in ./pipeline.ts
/**
* Process annotations in a single chapter file.
* Runs the full pipeline: parse -> context -> draft -> assemble -> version.
*/
export async function processChapter(plugin: PennyPlugin, file: TFile, options?: { silent?: boolean }): Promise<PipelineResult | null> {
plugin.processingFiles.add(file.path);
// Paths of files we create during processing -- added to processingFiles
// BEFORE vault.create to prevent the auto-save hook from picking them up.
const createdPaths: string[] = [];
try {
let content: string;
try {
content = await plugin.app.vault.read(file);
} catch (readErr) {
const msg = readErr instanceof Error ? readErr.message : String(readErr);
new Notice(`PENNY: Failed to read ${file.basename} -- ${msg}`);
return null;
}
// Quick check: any actionable annotations at all?
const allAnnotations = parseAnnotations(content);
const actionableCount = allAnnotations.filter((a) => a.actionable).length;
if (actionableCount === 0) {
if (!options?.silent) {
new Notice(`PENNY: No actionable annotations in ${file.basename}.`);
}
return null;
}
plugin.statusBar?.setProcessing();
try {
// TODO: Wire into parser -> context -> drafter -> assembler pipeline
// For now, report what would be processed
// Merge per-project config overrides from PENNY.md (if present)
let s = plugin.settings;
const projectOverrides = await findProjectConfig(file.path, plugin.app.vault);
if (projectOverrides) {
s = { ...s, ...projectOverrides };
}
// Normalize folder paths from settings
const reviewsFolder = normalizePath(s.reviewsFolder);
const activityLogFolder = normalizePath(s.activityLogFolder);
const folderPath = chapterFolderPath(file);
const chapterId = chapterIdFromFile(file);
const bookId = bookIdFromFile(file);
if (s.verboseLogging) {
console.log(`[PENNY] Processing ${file.path} (chapter: ${chapterId}, book: ${bookId})`);
}
// Read version and state files
const versionContent = await safeRead(plugin, `${folderPath}/.version`);
const stateContent = await safeRead(plugin, `${folderPath}/.state.json`);
// Detect characters for voice test pre-filtering
const { frontmatter: fm } = parseFrontmatter(content);
const focusField = typeof fm.focus === "string" ? fm.focus : "";
const detectedChars = detectCharacters(content, focusField);
// Gather context files from vault
const contextFiles = await gatherContextFiles(plugin, content, detectedChars, bookId);
if (s.verboseLogging) {
console.log(`[PENNY] Annotations found: ${actionableCount} actionable`);
}
// Pre-compute all output paths and add to processingFiles BEFORE the
// pipeline runs, preventing the auto-save hook from picking them up
// during the async pipeline execution.
let currentVersion = readVersion(versionContent);
let effectiveVersionContent = versionContent;
const expectedNextVersion = nextVersion(currentVersion);
let newVersionPath = `${folderPath}/${chapterId}.v${expectedNextVersion}.md`;
const versionFilePath = `${folderPath}/.version`;
const stateFilePath = `${folderPath}/.state.json`;
// MEDIUM-1: If a file at the expected version path already exists,
// scan the folder for the highest existing version and use that + 1.
const existingFile = plugin.app.vault.getAbstractFileByPath(newVersionPath);
if (existingFile) {
const folder = plugin.app.vault.getAbstractFileByPath(folderPath);
if (folder instanceof TFolder) {
let maxVer = 0;
for (const child of folder.children) {
const match = child.name.match(/\.v(\d+)\.md$/);
if (match) maxVer = Math.max(maxVer, parseInt(match[1]));
}
currentVersion = maxVer;
effectiveVersionContent = String(maxVer);
const actualNextVersion = nextVersion(maxVer);
newVersionPath = `${folderPath}/${chapterId}.v${actualNextVersion}.md`;
}
}
// Pre-compute review and log paths so they are in processingFiles
// BEFORE the pipeline runs (prevents auto-save hook from picking them up).
const effectiveNextVersion = nextVersion(currentVersion);
const reviewPath = getReviewFilePath(reviewsFolder, bookId, chapterId, effectiveNextVersion);
const logPath = getLogFilePath(activityLogFolder, bookId);
const newPaths = [newVersionPath, versionFilePath, stateFilePath, reviewPath, logPath];
for (const p of newPaths) {
plugin.processingFiles.add(p);
createdPaths.push(p);
}
// Run the pipeline
const result = await runPipeline({
content,
versionContent: effectiveVersionContent,
stateContent,
contextFiles,
settings: s,
chapterId,
bookId,
getProvider: (name: string) => {
if (s.verboseLogging) {
console.log(`[PENNY] Provider requested: ${name}`);
}
return plugin.providerRegistry.get(name);
},
});
if (!result) {
if (!options?.silent) {
new Notice(`PENNY: No new annotations to process in ${file.basename}.`);
}
return null;
}
if (s.verboseLogging) {
console.log(`[PENNY] Pipeline complete: ${result.annotationsProcessed} processed, v${result.newVersion} (${result.durationMs}ms)`);
}
// Write new version file
await plugin.app.vault.create(newVersionPath, result.newContent);
// Update .version file
const versionFile = plugin.app.vault.getAbstractFileByPath(versionFilePath);
if (versionFile && versionFile instanceof TFile) {
await plugin.app.vault.modify(versionFile, String(result.newVersion));
} else {
await plugin.app.vault.create(versionFilePath, String(result.newVersion));
}
// Update .state.json
const stateFile = plugin.app.vault.getAbstractFileByPath(stateFilePath);
if (stateFile && stateFile instanceof TFile) {
await plugin.app.vault.modify(stateFile, result.stateJson);
} else {
await plugin.app.vault.create(stateFilePath, result.stateJson);
}
// Write review note (reviewPath pre-computed above)
const reviewFolder = reviewPath.split("/").slice(0, -1).join("/");
await ensureFolder(plugin, reviewFolder);
await plugin.app.vault.create(reviewPath, result.reviewContent);
// Append to activity log (logPath pre-computed above)
const logFolder = logPath.split("/").slice(0, -1).join("/");
await ensureFolder(plugin, logFolder);
const existingLog = plugin.app.vault.getAbstractFileByPath(logPath);
if (existingLog && existingLog instanceof TFile) {
const existing = await plugin.app.vault.read(existingLog);
await plugin.app.vault.modify(existingLog, existing + result.logLine);
} else {
await plugin.app.vault.create(logPath, result.logLine);
}
if (s.verboseLogging) {
console.log(`[PENNY] Version ${result.newVersion} created for ${chapterId}`);
}
// Show completion notice
const errorNote = result.hadErrors ? " (with errors -- check review note)" : "";
new Notice(
`PENNY: Found ${counts.actionable} actionable annotation(s) in ${file.basename}. ` +
`Processing pipeline not yet wired. (Parser, context, drafter, assembler modules needed.)`
`PENNY: Processed ${result.annotationsProcessed} annotation(s) in ${file.basename}. ` +
`Created v${result.newVersion}${errorNote}. (${result.durationMs}ms)`,
8000,
);
// Auto-commit if enabled
if (s.autoCommitAfterProcessing) {
await gitCommit(plugin, {
chapter: chapterId,
version: String(result.newVersion),
tags: result.tags.join(", "),
createdPaths,
});
if (s.autoPushAfterCommit) {
await gitPush(plugin);
}
}
return result;
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
new Notice(`PENNY: Error processing ${file.basename} -- ${message}`);
return null;
} finally {
plugin.statusBar?.setReady();
const activeFile = plugin.app.workspace.getActiveFile();
@@ -273,6 +589,13 @@ async function processChapter(plugin: PennyPlugin, file: TFile): Promise<void> {
plugin.statusBar?.update(activeFile, plugin);
}
}
} finally {
plugin.processingFiles.delete(file.path);
// Clean up all created file paths from the processing guard
for (const p of createdPaths) {
plugin.processingFiles.delete(p);
}
}
}
/**
@@ -280,20 +603,33 @@ async function processChapter(plugin: PennyPlugin, file: TFile): Promise<void> {
*/
async function dryRun(plugin: PennyPlugin, file: TFile): Promise<void> {
const content = await plugin.app.vault.read(file);
const counts = countAnnotations(content);
const total = counts.actionable + counts.passthrough;
const annotations = parseAnnotations(content);
const actionableCount = annotations.filter((a) => a.actionable).length;
const passthroughCount = annotations.filter((a) => !a.actionable).length;
const total = annotations.length;
const lines: string[] = [
`PENNY Dry Run: ${file.basename}`,
`---`,
`Total annotations: ${total}`,
` Actionable: ${counts.actionable} (would be processed)`,
` NOTE/RESEARCH: ${counts.passthrough} (would be preserved)`,
` Actionable: ${actionableCount} (would be processed)`,
` NOTE/RESEARCH: ${passthroughCount} (would be preserved)`,
];
if (counts.actionable === 0) {
if (actionableCount === 0) {
lines.push("", "Nothing to process.");
} else {
// Show per-tag breakdown
const tagCounts: Record<string, number> = {};
for (const a of annotations) {
if (a.actionable) {
tagCounts[a.tag] = (tagCounts[a.tag] || 0) + 1;
}
}
lines.push("", "Breakdown:");
for (const [tag, count] of Object.entries(tagCounts)) {
lines.push(` ${tag}: ${count}`);
}
lines.push("", `Running "Process this chapter" would create a new version.`);
}
@@ -305,7 +641,9 @@ async function dryRun(plugin: PennyPlugin, file: TFile): Promise<void> {
*/
async function showStatus(plugin: PennyPlugin, file: TFile): Promise<void> {
const content = await plugin.app.vault.read(file);
const counts = countAnnotations(content);
const annotations = parseAnnotations(content);
const actionableCount = annotations.filter((a) => a.actionable).length;
const passthroughCount = annotations.filter((a) => !a.actionable).length;
const versionMatch = file.basename.match(/\.v(\d+)$/);
const version = versionMatch ? versionMatch[1] : "unknown";
@@ -314,8 +652,8 @@ async function showStatus(plugin: PennyPlugin, file: TFile): Promise<void> {
`PENNY Status: ${file.basename}`,
`---`,
`Version: ${version}`,
`Actionable annotations: ${counts.actionable}`,
`Notes/research: ${counts.passthrough}`,
`Actionable annotations: ${actionableCount}`,
`Notes/research: ${passthroughCount}`,
`---`,
`Annotation syntax: %% TAG: instruction %%`,
`Tags: REWRITE, EXPAND, CUT, TONE, DIALOG, PLOT, PACING, CHARACTER`,
@@ -329,7 +667,7 @@ async function showStatus(plugin: PennyPlugin, file: TFile): Promise<void> {
* Migrate flat chapter files to versioned folder structure.
*/
async function migrateChapters(plugin: PennyPlugin): Promise<void> {
const draftsFolder = plugin.settings.draftsFolder;
const draftsFolder = normalizePath(plugin.settings.draftsFolder);
const abstractFile = plugin.app.vault.getAbstractFileByPath(draftsFolder);
if (!abstractFile || !(abstractFile instanceof TFolder)) {
@@ -373,10 +711,30 @@ async function migrateChapters(plugin: PennyPlugin): Promise<void> {
// Write versioned file
await plugin.app.vault.create(versionedFilePath, content);
// Verify the written file matches the original content
const versionedFile = plugin.app.vault.getAbstractFileByPath(versionedFilePath);
if (!versionedFile || !(versionedFile instanceof TFile)) {
new Notice(
`PENNY: Migration verification failed for ${bookChild.path} -- versioned file not found after write. Original left intact.`
);
continue;
}
const writtenContent = await plugin.app.vault.read(versionedFile);
if (writtenContent !== content) {
new Notice(
`PENNY: Migration verification failed for ${bookChild.path} -- written content does not match original. Original left intact.`
);
continue;
}
// Write .version manifest
await plugin.app.vault.create(versionFilePath, "1");
// Remove original flat file
// Write .state.json
const stateFilePath = `${chapterFolderPath}/.state.json`;
await plugin.app.vault.create(stateFilePath, generateStateJson());
// Remove original flat file only after verified write
await plugin.app.vault.delete(bookChild);
migratedCount++;
@@ -390,7 +748,9 @@ async function migrateChapters(plugin: PennyPlugin): Promise<void> {
}
if (migratedCount === 0 && skippedCount === 0) {
new Notice("PENNY: No chapter files found to migrate.");
new Notice(
`PENNY: No flat chapter files found. Chapters may already be migrated, or check that your drafts folder structure matches: ${draftsFolder}/{book-N}/ch-XX.md`,
);
} else {
new Notice(
`PENNY: Migration complete. Migrated: ${migratedCount}, already migrated: ${skippedCount}.`
@@ -403,14 +763,16 @@ async function migrateChapters(plugin: PennyPlugin): Promise<void> {
*/
async function createNewChapter(plugin: PennyPlugin): Promise<void> {
const activeFile = plugin.app.workspace.getActiveFile();
const draftsFolder = plugin.settings.draftsFolder;
const draftsFolder = normalizePath(plugin.settings.draftsFolder);
// Try to infer the book folder from the active file
// Try to infer the book folder from the active file.
// Compute depth dynamically so multi-segment draftsFolder paths work correctly.
let bookFolderPath: string | null = null;
if (activeFile && activeFile.path.startsWith(draftsFolder + "/")) {
const parts = activeFile.path.split("/");
if (parts.length >= 2) {
bookFolderPath = parts.slice(0, 2).join("/");
const draftsParts = draftsFolder.split("/").length;
if (parts.length > draftsParts) {
bookFolderPath = parts.slice(0, draftsParts + 1).join("/");
}
}
@@ -449,9 +811,10 @@ async function createNewChapter(plugin: PennyPlugin): Promise<void> {
await ensureFolder(plugin, chapterFolderPath);
const newFile = await plugin.app.vault.create(filePath, content);
await plugin.app.vault.create(`${chapterFolderPath}/.version`, "1");
await plugin.app.vault.create(`${chapterFolderPath}/.state.json`, generateStateJson());
// Open the new file
await plugin.app.workspace.getLeaf(false).openFile(newFile);
await plugin.app.workspace.getLeaf("tab").openFile(newFile);
new Notice(`PENNY: Created ${chapterName} in ${bookFolderPath}.`);
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
@@ -516,7 +879,7 @@ async function createNewCharacter(plugin: PennyPlugin): Promise<void> {
try {
const newFile = await plugin.app.vault.create(filePath, content);
await plugin.app.workspace.getLeaf(false).openFile(newFile);
await plugin.app.workspace.getLeaf("tab").openFile(newFile);
new Notice("PENNY: Created new character file. Rename it to match the character.");
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
@@ -538,6 +901,9 @@ async function createNewCharacter(plugin: PennyPlugin): Promise<void> {
*/
function gitExec(plugin: PennyPlugin, args: string[]): Promise<string> {
const vaultPath = getVaultBasePath(plugin);
if (!vaultPath) {
return Promise.reject(new Error("Vault adapter is not a local filesystem"));
}
return new Promise((resolve, reject) => {
execFile(
"git",
@@ -556,18 +922,93 @@ function gitExec(plugin: PennyPlugin, args: string[]): Promise<string> {
/**
* Get the vault's base filesystem path.
* Returns null if the adapter is not a FileSystemAdapter (e.g., a sync adapter).
*/
function getVaultBasePath(plugin: PennyPlugin): string {
// The basePath property exists on FileSystemAdapter at runtime but is
// not part of the public Obsidian type definitions.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return (plugin.app.vault.adapter as any).basePath as string;
function getVaultBasePath(plugin: PennyPlugin): string | null {
if (!(plugin.app.vault.adapter instanceof FileSystemAdapter)) {
return null;
}
return plugin.app.vault.adapter.getBasePath();
}
/**
* Verify all prerequisites for git operations:
* 1. Vault uses a local FileSystemAdapter
* 2. Git is installed and on PATH
* 3. The vault directory is inside a git repository
*
* Returns the vault path on success, or null after showing a Notice on failure.
*/
async function verifyGitPrerequisites(plugin: PennyPlugin): Promise<string | null> {
const vaultPath = getVaultBasePath(plugin);
if (!vaultPath) {
new Notice("PENNY: Git operations require a local vault (not a sync adapter).");
return null;
}
// Check git is installed
try {
await gitExecRaw(["--version"]);
} catch {
new Notice("PENNY: Git is not installed or not on PATH.");
return null;
}
// Check vault is a git repo
try {
const result = await gitExecRaw(["-C", vaultPath, "rev-parse", "--is-inside-work-tree"]);
if (result.trim() !== "true") {
new Notice("PENNY: This vault is not a git repository. Run 'git init' first.");
return null;
}
} catch {
new Notice("PENNY: This vault is not a git repository. Run 'git init' first.");
return null;
}
return vaultPath;
}
/**
* Execute a raw git command (without vault path prefix).
* Used for prerequisite checks like `git --version`.
*/
function gitExecRaw(args: string[]): Promise<string> {
return new Promise((resolve, reject) => {
execFile(
"git",
args,
{ timeout: 10000 },
(error, stdout, stderr) => {
if (error) {
reject(new Error(stderr || error.message));
} else {
resolve(stdout.trim());
}
}
);
});
}
/** Optional processing result info for building formatted commit messages. */
interface CommitContext {
chapter?: string;
version?: string;
tags?: string;
/** Paths that PENNY created/modified during processing -- staged individually. */
createdPaths?: string[];
}
/**
* Stage and commit changes with an auto-generated message.
*
* When called with a CommitContext (after processing), uses the user's
* commitMessageFormat template. Otherwise builds a message from changed files.
*/
async function gitCommit(plugin: PennyPlugin): Promise<void> {
async function gitCommit(plugin: PennyPlugin, ctx?: CommitContext): Promise<void> {
const vaultPath = await verifyGitPrerequisites(plugin);
if (!vaultPath) return;
try {
// Check for changes
const status = await gitExec(plugin, ["status", "--porcelain"]);
@@ -587,7 +1028,11 @@ async function gitCommit(plugin: PennyPlugin): Promise<void> {
const otherChanges: string[] = [];
for (const line of changedFiles) {
const filePath = line.slice(3); // Strip status prefix (e.g., " M " or "?? ")
let filePath = line.slice(3); // Strip status prefix (e.g., " M " or "?? ")
// Handle renamed/copied files: "R old -> new" format
if (filePath.includes(" -> ")) {
filePath = filePath.split(" -> ").pop() ?? filePath;
}
if (filePath.includes("04-drafts") || filePath.includes("drafts")) {
const chMatch = filePath.match(/ch-\d+/);
if (chMatch && !chapterChanges.includes(chMatch[0])) {
@@ -603,9 +1048,18 @@ async function gitCommit(plugin: PennyPlugin): Promise<void> {
}
}
let message = "docs: PENNY writing session";
if (chapterChanges.length > 0) {
// Use commitMessageFormat template when we have processing context
let message: string;
const fmt = plugin.settings.commitMessageFormat;
if (ctx && fmt) {
message = fmt
.replace(/\{chapter\}/g, ctx.chapter ?? chapterChanges.join(", ") ?? "unknown")
.replace(/\{version\}/g, ctx.version ?? "?")
.replace(/\{tags\}/g, ctx.tags ?? "");
} else if (chapterChanges.length > 0) {
message = `docs: revise ${chapterChanges.join(", ")}`;
} else {
message = "docs: PENNY writing session";
}
const bodyParts: string[] = [];
@@ -624,8 +1078,18 @@ async function gitCommit(plugin: PennyPlugin): Promise<void> {
? `${message}\n\n${bodyParts.join("\n")}`
: message;
// Stage and commit
await gitExec(plugin, ["add", "-A"]);
// Stage only PENNY-related files
if (ctx?.createdPaths?.length) {
for (const p of ctx.createdPaths) {
await gitExec(plugin, ["add", p]);
}
} else {
// Fallback: stage only drafts and reviews folders
const s = plugin.settings;
if (s.draftsFolder) await gitExec(plugin, ["add", normalizePath(s.draftsFolder)]);
if (s.reviewsFolder) await gitExec(plugin, ["add", normalizePath(s.reviewsFolder)]);
if (s.activityLogFolder) await gitExec(plugin, ["add", normalizePath(s.activityLogFolder)]);
}
await gitExec(plugin, ["commit", "-m", fullMessage]);
new Notice(`PENNY: Committed. ${message}`);
@@ -639,6 +1103,9 @@ async function gitCommit(plugin: PennyPlugin): Promise<void> {
* Push to the remote repository.
*/
async function gitPush(plugin: PennyPlugin): Promise<void> {
const vaultPath = await verifyGitPrerequisites(plugin);
if (!vaultPath) return;
try {
await gitExec(plugin, ["push"]);
new Notice("PENNY: Pushed to remote.");
@@ -657,7 +1124,7 @@ async function gitPush(plugin: PennyPlugin): Promise<void> {
*/
async function ensureFolder(plugin: PennyPlugin, path: string): Promise<void> {
const existing = plugin.app.vault.getAbstractFileByPath(path);
if (existing) return;
if (existing instanceof TFolder) return;
// Create parent folders recursively
const parts = path.split("/");
@@ -665,7 +1132,7 @@ async function ensureFolder(plugin: PennyPlugin, path: string): Promise<void> {
for (const part of parts) {
current = current ? `${current}/${part}` : part;
const folder = plugin.app.vault.getAbstractFileByPath(current);
if (!folder) {
if (!(folder instanceof TFolder)) {
await plugin.app.vault.createFolder(current);
}
}
@@ -1034,6 +1501,10 @@ async function scaffoldProject(
`${root}/04-drafts/book-${b}/ch-${padded}/.version`,
"1"
);
await plugin.app.vault.create(
`${root}/04-drafts/book-${b}/ch-${padded}/.state.json`,
generateStateJson()
);
}
}
@@ -1318,6 +1789,13 @@ export class ProjectInitModal extends Modal {
return;
}
// Validate project name
const name = this.options.projectName;
if (name.includes("..") || name.startsWith("/") || /[:<>"|?*]/.test(name)) {
new Notice("PENNY: Project name cannot contain path traversal sequences or special characters (: < > \" | ? *).");
return;
}
button.setDisabled(true);
button.setButtonText("Creating...");
+24 -15
View File
@@ -26,12 +26,15 @@ export interface ContextFiles {
/**
* Estimate token count from text.
* Heuristic: 1 word ~ 1.33 tokens.
*
* @param text The text to estimate tokens for.
* @param multiplier Words-to-tokens ratio. Defaults to 1.33 (Anthropic).
* Ollama-style providers may use 1.0 or a different value.
*/
export function estimateTokens(text: string): number {
export function estimateTokens(text: string, multiplier = 1.33): number {
if (!text || text.trim().length === 0) return 0;
const wordCount = text.trim().split(/\s+/).length;
return Math.ceil(wordCount * 1.33);
return Math.ceil(wordCount * multiplier);
}
/**
@@ -102,46 +105,52 @@ export function selectVoiceTestSection(
* 7. Series bible (if space permits)
* 8. Themes (if space permits)
*/
/**
* @param tokenMultiplier Words-to-tokens ratio for the active provider.
* Defaults to 1.33 (Anthropic). Pass the value from
* the provider's `tokenEstimationMultiplier()`.
*/
export function assembleContext(
files: ContextFiles,
annotation: AnnotatedSection,
settings: PennySettings,
tokenMultiplier = 1.33,
): AssembledContext {
const budget = settings.contextBudget;
let remaining = budget;
// Priority 1: chapter (always included, never truncated).
const chapter = files.chapter;
remaining -= estimateTokens(chapter);
remaining -= estimateTokens(chapter, tokenMultiplier);
// Priority 2: voice tests.
// Priority 2: voice tests (budget-gated like all other context).
let voiceTests = "";
if (files.voiceTests) {
if (files.voiceTests && remaining > 0) {
// We don't know characters yet at this layer, so include full voice tests.
// The caller can pre-filter using selectVoiceTestSection.
voiceTests = files.voiceTests;
remaining -= estimateTokens(voiceTests);
remaining -= estimateTokens(voiceTests, tokenMultiplier);
}
// Priority 3: style guide.
let styleGuide = "";
if (files.styleGuide && remaining > 0) {
styleGuide = files.styleGuide;
remaining -= estimateTokens(styleGuide);
remaining -= estimateTokens(styleGuide, tokenMultiplier);
}
// Priority 4: outline.
let outline = "";
if (files.outline && remaining > 0) {
outline = files.outline;
remaining -= estimateTokens(outline);
remaining -= estimateTokens(outline, tokenMultiplier);
}
// Priority 5: characters.
let characters = "";
if (files.characters && files.characters.length > 0 && remaining > 0) {
const combined = files.characters.join("\n\n---\n\n");
const tokens = estimateTokens(combined);
const tokens = estimateTokens(combined, tokenMultiplier);
if (tokens <= remaining) {
characters = combined;
remaining -= tokens;
@@ -149,7 +158,7 @@ export function assembleContext(
// Include as many character files as fit.
const parts: string[] = [];
for (const charFile of files.characters) {
const t = estimateTokens(charFile);
const t = estimateTokens(charFile, tokenMultiplier);
if (t <= remaining) {
parts.push(charFile);
remaining -= t;
@@ -163,14 +172,14 @@ export function assembleContext(
let wiki = "";
if (files.wiki && files.wiki.length > 0 && remaining > 0) {
const combined = files.wiki.join("\n\n---\n\n");
const tokens = estimateTokens(combined);
const tokens = estimateTokens(combined, tokenMultiplier);
if (tokens <= remaining) {
wiki = combined;
remaining -= tokens;
} else {
const parts: string[] = [];
for (const entry of files.wiki) {
const t = estimateTokens(entry);
const t = estimateTokens(entry, tokenMultiplier);
if (t <= remaining) {
parts.push(entry);
remaining -= t;
@@ -183,7 +192,7 @@ export function assembleContext(
// Priority 7: series bible.
let seriesBible = "";
if (files.seriesBible && remaining > 0) {
const tokens = estimateTokens(files.seriesBible);
const tokens = estimateTokens(files.seriesBible, tokenMultiplier);
if (tokens <= remaining) {
seriesBible = files.seriesBible;
remaining -= tokens;
@@ -193,7 +202,7 @@ export function assembleContext(
// Priority 8: themes.
let themes = "";
if (files.themes && remaining > 0) {
const tokens = estimateTokens(files.themes);
const tokens = estimateTokens(files.themes, tokenMultiplier);
if (tokens <= remaining) {
themes = files.themes;
remaining -= tokens;
+33 -38
View File
@@ -1,11 +1,14 @@
/**
* PENNY - Prompt Construction
* PENNY - Prompt Construction & Provider Dispatch
*
* Build the system and user prompts from assembled context and an annotation.
* Pure functions -- no Obsidian API dependencies.
* Dispatch completion requests to the active LLM provider.
* Pure functions -- no Obsidian API dependencies (except callProvider which
* accepts the provider as a parameter).
*/
import type { AssembledContext, AnnotatedSection } from "./types";
import type { CompletionRequest, CompletionResponse } from "./providers/service";
/**
* All placeholders that can appear in the system prompt template.
@@ -58,11 +61,11 @@ export function buildPrompt(
}
// Replace annotation-level placeholders.
system = system.replace(/\{tag\}/g, annotation.tag);
system = system.replace(/\{lineStart\}/g, String(annotation.lineStart));
system = system.replace(/\{lineEnd\}/g, String(annotation.lineEnd));
system = system.replace(/\{passage\}/g, annotation.originalText);
system = system.replace(/\{instruction\}/g, annotation.instruction);
system = system.replace(/\{tag\}/g, () => annotation.tag);
system = system.replace(/\{lineStart\}/g, () => String(annotation.lineStart));
system = system.replace(/\{lineEnd\}/g, () => String(annotation.lineEnd));
system = system.replace(/\{passage\}/g, () => annotation.originalText);
system = system.replace(/\{instruction\}/g, () => annotation.instruction);
// Clean up any double blank lines left by section removal.
system = system.replace(/\n{3,}/g, "\n\n");
@@ -86,38 +89,30 @@ export function buildPrompt(
}
/**
* Parse the text content from an Anthropic Messages API JSON response.
*
* The response shape is:
* ```json
* {
* "content": [
* { "type": "thinking", "thinking": "..." },
* { "type": "text", "text": "the actual output" }
* ]
* }
* ```
*
* We extract the first `text` block's content.
* Minimal provider interface accepted by callProvider. Matches
* PipelineProvider from pipeline.ts -- only the complete() method is needed.
*/
export function parseApiResponse(responseText: string): string {
try {
const data = JSON.parse(responseText);
if (data && Array.isArray(data.content)) {
const textBlock = data.content.find(
(block: Record<string, unknown>) => block.type === "text",
);
if (textBlock && typeof textBlock.text === "string") {
return textBlock.text.trim();
}
}
// Fallback: if the response is a plain string.
if (typeof data === "string") return data.trim();
return "";
} catch {
// If not valid JSON, return the raw text (might be a plain-text response).
return responseText.trim();
}
interface CallableProvider {
complete(request: CompletionRequest): Promise<CompletionResponse>;
}
/**
* Send a completion request through the given LLM provider.
*
* The provider already has the HTTP function injected at construction time
* (via the registry), so callers just need to pass the request with the
* appropriate API key and endpoint from settings.
*
* @param provider Any object with a complete() method (LLMService, PipelineProvider, etc.)
* @param request The completion request (systemPrompt, userPrompt, model, maxTokens,
* plus optional apiKey and endpoint for the provider).
* @returns The provider's response including the generated text.
*/
export async function callProvider(
provider: CallableProvider,
request: CompletionRequest,
): Promise<CompletionResponse> {
return provider.complete(request);
}
/** Escape special regex characters in a string. */
+63 -9
View File
@@ -23,17 +23,19 @@ const FRONTMATTER_RE = /^---\r?\n([\s\S]*?)\r?\n---/;
export function parseFrontmatter(content: string): {
frontmatter: ChapterFrontmatter;
body: string;
keyOrder: string[];
} {
const match = content.match(FRONTMATTER_RE);
if (!match) {
return { frontmatter: {}, body: content };
return { frontmatter: {}, body: content, keyOrder: [] };
}
const yamlBlock = match[1];
const body = content.slice(match[0].length).replace(/^\r?\n/, "");
const frontmatter: ChapterFrontmatter = {};
const keyOrder: string[] = [];
const lines = yamlBlock.split("\n");
const lines = yamlBlock.split(/\r?\n/);
let currentKey: string | null = null;
let currentArray: string[] | null = null;
@@ -59,6 +61,11 @@ export function parseFrontmatter(content: string): {
const key = kvMatch[1];
const rawValue = kvMatch[2].trim();
// Track key order
if (!keyOrder.includes(key)) {
keyOrder.push(key);
}
// If value is empty, this might be the start of an array.
if (rawValue === "" || rawValue === "[]") {
currentKey = key;
@@ -86,7 +93,7 @@ export function parseFrontmatter(content: string): {
(frontmatter as Record<string, unknown>)[currentKey] = currentArray;
}
return { frontmatter, body };
return { frontmatter, body, keyOrder };
}
function parseYamlValue(raw: string): string | number | boolean {
@@ -104,14 +111,38 @@ function parseYamlValue(raw: string): string | number | boolean {
return raw;
}
/**
* Check if a string value needs quoting for safe YAML serialization.
* Values containing YAML-unsafe characters must be double-quoted.
*/
function needsYamlQuoting(value: string): boolean {
if (value.length === 0) return true;
// Leading/trailing whitespace
if (value !== value.trim()) return true;
// Starts with a YAML-special character
if (/^[@!*&'"]/.test(value)) return true;
// Contains characters that could break YAML parsing
if (/[:#\[\]{}"']/.test(value)) return true;
return false;
}
/**
* Quote a string value for YAML serialization using double quotes.
* Internal double quotes are escaped.
*/
function quoteYamlValue(value: string): string {
const escaped = value.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
return `"${escaped}"`;
}
/**
* Serialize frontmatter and body back into a markdown string.
*/
export function serializeFrontmatter(frontmatter: ChapterFrontmatter, body: string): string {
export function serializeFrontmatter(frontmatter: ChapterFrontmatter, body: string, keyOrder?: string[]): string {
const yamlLines: string[] = [];
for (const [key, value] of Object.entries(frontmatter)) {
if (value === undefined || value === null) continue;
const serializeKey = (key: string, value: unknown): void => {
if (value === undefined || value === null) return;
if (Array.isArray(value)) {
if (value.length === 0) {
@@ -119,14 +150,37 @@ export function serializeFrontmatter(frontmatter: ChapterFrontmatter, body: stri
} else {
yamlLines.push(`${key}:`);
for (const item of value) {
yamlLines.push(` - ${item}`);
const itemStr = String(item);
yamlLines.push(` - ${needsYamlQuoting(itemStr) ? quoteYamlValue(itemStr) : itemStr}`);
}
}
} else if (typeof value === "string" && value === "") {
yamlLines.push(`${key}: ""`);
} else if (typeof value === "string" && needsYamlQuoting(value)) {
yamlLines.push(`${key}: ${quoteYamlValue(value)}`);
} else {
yamlLines.push(`${key}: ${value}`);
}
};
// Serialize in original key order first, then any new keys
const allKeys = Object.keys(frontmatter);
const seen = new Set<string>();
if (keyOrder && keyOrder.length > 0) {
for (const key of keyOrder) {
if (key in frontmatter) {
serializeKey(key, (frontmatter as Record<string, unknown>)[key]);
seen.add(key);
}
}
}
// Append any keys not in the original order (new agent-added fields)
for (const key of allKeys) {
if (!seen.has(key)) {
serializeKey(key, (frontmatter as Record<string, unknown>)[key]);
}
}
const yaml = yamlLines.join("\n");
@@ -160,8 +214,8 @@ export function countProseWords(content: string, proseMarker: string): number {
}
}
// Strip markdown comments %% ... %%
text = text.replace(/%%.*?%%/g, "");
// Strip markdown comments %% ... %% (including multi-line)
text = text.replace(/%%[\s\S]*?%%/g, "");
// Strip HTML comments <!-- ... -->
text = text.replace(/<!--[\s\S]*?-->/g, "");
// Strip markdown headings markers (keep the text)
+64 -56
View File
@@ -8,11 +8,15 @@
* @module main
*/
import { Plugin, Notice, TFile } from "obsidian";
import { Plugin, Notice, TFile, requestUrl } from "obsidian";
import { PennySettingTab } from "./settings";
import { PennyStatusBar } from "./statusbar";
import { registerCommands } from "./commands";
import { DEFAULT_SETTINGS } from "./types";
import { registerCommands, processChapter, requireProvider } from "./commands";
import { DEFAULT_SETTINGS, migrateSettings } from "./types";
import { createRegistry } from "./providers";
import { globMatch } from "./utils";
import type { ProviderRegistry } from "./providers/registry";
import type { HttpRequestParam } from "./providers/service";
import type { PennySettings } from "./types";
/**
@@ -38,19 +42,43 @@ export default class PennyPlugin extends Plugin {
/** Current plugin settings */
settings: PennySettings = { ...DEFAULT_SETTINGS };
/** LLM provider registry */
providerRegistry!: ProviderRegistry;
/** Status bar widget instance */
statusBar: PennyStatusBar | null = null;
/** Reference to the file-modify event, for cleanup */
private saveEventRef: ReturnType<typeof this.app.vault.on> | null = null;
/** Whether auto-processing is currently in progress */
private autoProcessing = false;
/** Set of file paths currently being processed (prevents re-entrant processing) */
processingFiles: Set<string> = new Set();
/** Pending debounce timers keyed by file path */
private debounceTimers: Map<string, ReturnType<typeof setTimeout>> = new Map();
async onload(): Promise<void> {
await this.loadSettings();
// Register the settings tab
// Create LLM provider registry with Obsidian's requestUrl as the HTTP layer.
// The adapter maps requestUrl's response to the HttpResponse shape providers expect.
this.providerRegistry = createRegistry(async (params: HttpRequestParam) => {
const resp = await requestUrl({
url: params.url,
method: params.method,
headers: params.headers,
body: params.body,
contentType: params.contentType,
});
return {
status: resp.status,
headers: resp.headers,
text: resp.text,
json: resp.json,
};
});
// Register the settings tab (receives the registry for model dropdowns)
this.addSettingTab(new PennySettingTab(this.app, this));
// Register all commands
@@ -82,10 +110,11 @@ export default class PennyPlugin extends Plugin {
this.statusBar.update(activeFile, this);
}
// First-run notice if no API key is set
if (!this.settings.apiKey) {
// 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. Set your Anthropic API key in Settings > PENNY to get started.",
"PENNY loaded. Configure an LLM provider in Settings > PENNY to get started.",
8000
);
}
@@ -102,10 +131,12 @@ export default class PennyPlugin extends Plugin {
/**
* Load settings from Obsidian's data store, merging with defaults.
* Applies migration for legacy `apiKey` / `model` fields.
*/
async loadSettings(): Promise<void> {
const data = await this.loadData();
this.settings = { ...DEFAULT_SETTINGS, ...(data ?? {}) };
const raw = await this.loadData();
const migrated = raw ? migrateSettings(raw as Record<string, unknown>) : {};
this.settings = { ...DEFAULT_SETTINGS, ...migrated };
}
/**
@@ -129,6 +160,12 @@ export default class PennyPlugin extends Plugin {
this.saveEventRef = null;
}
// Clear any pending debounce timers
for (const timer of this.debounceTimers.values()) {
clearTimeout(timer);
}
this.debounceTimers.clear();
if (!this.settings.autoProcessOnSave) return;
this.saveEventRef = this.app.vault.on("modify", (file) => {
@@ -137,16 +174,24 @@ export default class PennyPlugin extends Plugin {
// Only trigger for chapter files
if (!this.isChapterFile(file)) return;
// Debounce: skip if already auto-processing
if (this.autoProcessing) return;
// Skip files currently being processed
if (this.processingFiles.has(file.path)) return;
this.autoProcessing = true;
executeCommand(this.app, "penny:process-chapter");
// Debounce: cancel any pending timer for this file, then set a new one
const existing = this.debounceTimers.get(file.path);
if (existing) {
clearTimeout(existing);
}
// Reset the flag after a short delay to allow the command to complete
setTimeout(() => {
this.autoProcessing = false;
}, 1000);
const timer = setTimeout(() => {
this.debounceTimers.delete(file.path);
// Double-check the file is still not being processed
if (!this.processingFiles.has(file.path) && requireProvider(this)) {
processChapter(this, file);
}
}, 2000);
this.debounceTimers.set(file.path, timer);
});
}
@@ -158,40 +203,3 @@ export default class PennyPlugin extends Plugin {
return globMatch(this.settings.chapterFilePattern, file.name);
}
}
/**
* Simple glob matcher supporting * and ? wildcards.
* Uses iterative comparison to avoid ReDoS.
*/
function globMatch(pattern: string, text: string): boolean {
let pi = 0;
let ti = 0;
let starPi = -1;
let matchTi = -1;
while (ti < text.length) {
if (
pi < pattern.length &&
(pattern[pi] === text[ti] || pattern[pi] === "?")
) {
pi++;
ti++;
} else if (pi < pattern.length && pattern[pi] === "*") {
starPi = pi;
matchTi = ti;
pi++;
} else if (starPi !== -1) {
pi = starPi + 1;
matchTi++;
ti = matchTi;
} else {
return false;
}
}
while (pi < pattern.length && pattern[pi] === "*") {
pi++;
}
return pi === pattern.length;
}
+8 -16
View File
@@ -13,15 +13,14 @@ import {
ALL_TAGS,
} from "./types";
/** Regex matching a single annotation: %% TAG: instruction %% (global, for exec loops) */
const ANNOTATION_RE = /%%\s*([A-Z]+)\s*:\s*(.*?)\s*%%/g;
/** Non-global regex pattern for matching annotations. A new RegExp is created per use. */
const ANNOTATION_PATTERN = /%%\s*([A-Z]+)\s*:\s*(.*?)\s*%%/;
/**
* Strip all annotation markers from a string.
* Uses a fresh regex each call to avoid lastIndex state pollution.
*/
function stripAnnotations(text: string): string {
return text.replace(/%%\s*[A-Z]+\s*:\s*.*?\s*%%/g, "");
return text.replace(new RegExp(ANNOTATION_PATTERN.source, "g"), "");
}
/**
@@ -45,7 +44,8 @@ function hashAnnotation(tag: string, instruction: string, originalText: string):
h2 = Math.imul(h2 ^ (h2 >>> 16), 2246822507);
h2 ^= Math.imul(h1 ^ (h1 >>> 13), 3266489909);
const n = 4294967296 * (2097151 & h2) + (h1 >>> 0);
return n.toString(16).padStart(12, "0").slice(0, 12);
// Use slice(-12) to keep the least-significant (most entropic) hex digits
return n.toString(16).padStart(12, "0").slice(-12);
}
function isValidTag(tag: string): tag is AnnotationTag {
@@ -140,11 +140,10 @@ export function parseAnnotations(content: string): AnnotatedSection[] {
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
// Reset regex state for this line
ANNOTATION_RE.lastIndex = 0;
let match: RegExpExecArray | null;
// Create a fresh global regex for each line to avoid shared state issues
const lineRegex = new RegExp(ANNOTATION_PATTERN.source, "g");
while ((match = ANNOTATION_RE.exec(line)) !== null) {
for (const match of line.matchAll(lineRegex)) {
const tag = match[1];
const instruction = match[2].trim();
@@ -159,10 +158,6 @@ export function parseAnnotations(content: string): AnnotatedSection[] {
// For inline scope the original text is the line content minus annotations.
// For paragraph / section the original text is the range [lineStart..lineEnd]
// excluding lines that are purely annotations.
// Save and restore lastIndex around scope resolution and text extraction
// to prevent the global ANNOTATION_RE state from being corrupted.
const savedLastIndex = ANNOTATION_RE.lastIndex;
let originalText: string;
if (scope === "inline") {
originalText = stripAnnotations(line).trim();
@@ -178,9 +173,6 @@ export function parseAnnotations(content: string): AnnotatedSection[] {
originalText = passageLines.join("\n").trim();
}
// Restore regex state so the exec loop can continue correctly.
ANNOTATION_RE.lastIndex = savedLastIndex;
const actionable = isActionableTag(tag);
const hash = hashAnnotation(tag, instruction, originalText);
+308
View File
@@ -0,0 +1,308 @@
/**
* PENNY - Core Processing Pipeline (pure logic)
*
* 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.
*/
import type { AnnotatedSection, AnnotationChange, ReviewFlag, PennySettings } from "./types";
import type { ContextFiles } from "./context";
import type { CompletionRequest, CompletionResponse } from "./providers/service";
import type { RouteConfig, ComplexityTier } from "./providers/router";
import { TAG_COMPLEXITY } from "./providers/router";
import { parseAnnotations } from "./parser";
import { assembleContext } from "./context";
import { buildPrompt, callProvider } from "./drafter";
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 { 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.
*/
export interface PipelineProvider {
complete(request: CompletionRequest): Promise<CompletionResponse>;
/** Optional token multiplier for this provider (words-to-tokens ratio). */
tokenMultiplier?: number;
}
export interface PipelineInput {
content: string;
versionContent: string;
stateContent: string;
contextFiles: ContextFiles;
settings: PennySettings;
chapterId: string;
bookId: string;
/** Provider lookup: given a provider name, returns an object with a complete() method, or undefined */
getProvider: (name: string) => PipelineProvider | undefined;
}
export interface PipelineResult {
/** The assembled new version content (with frontmatter) */
newContent: string;
/** The new version number */
newVersion: number;
/** Serialized state JSON */
stateJson: string;
/** Review note markdown */
reviewContent: string;
/** Activity log entry line (JSONL) */
logLine: string;
/** Number of annotations processed */
annotationsProcessed: number;
/** Number of annotations skipped (passthrough) */
annotationsSkipped: number;
/** Tags that were processed */
tags: string[];
/** Duration in ms */
durationMs: number;
/** Whether there were any errors during individual annotation processing */
hadErrors: boolean;
}
/**
* Build the RouteConfig from plugin settings.
* When useSameModelForAll is true, all tiers use the standard route.
*/
export function buildRouteConfig(s: PennySettings): RouteConfig {
if (s.useSameModelForAll) {
return {
light: { ...s.routeStandard },
standard: { ...s.routeStandard },
heavy: { ...s.routeStandard },
};
}
return {
light: { ...s.routeLight },
standard: { ...s.routeStandard },
heavy: { ...s.routeHeavy },
};
}
/**
* 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.
*/
export async function runPipeline(input: PipelineInput): Promise<PipelineResult | null> {
const startTime = Date.now();
const { content, settings, chapterId, bookId } = input;
// (a) Parse annotations
const allAnnotations = parseAnnotations(content);
// (b) Read version and state
const currentVersion = readVersion(input.versionContent);
const state = readState(input.stateContent);
// (c) Filter to only new actionable annotations
const toProcess = shouldProcess(allAnnotations, state);
if (toProcess.length === 0) {
return null; // nothing to do
}
// Build route config
const routeConfig = buildRouteConfig(settings);
// Count passthrough annotations
const passthroughCount = allAnnotations.filter((a) => !a.actionable).length;
// Word count before processing
const wordCountBefore = countProseWords(content, settings.proseMarker);
// (e) Process each annotation via the LLM provider
const revisions: Array<{ annotation: AnnotatedSection; revisedText: string }> = [];
const changes: AnnotationChange[] = [];
const flags: ReviewFlag[] = [];
const processedTags: string[] = [];
let hadErrors = false;
for (const annotation of toProcess) {
const route = getRoute(annotation.tag, routeConfig);
const provider = input.getProvider(route.provider);
if (!provider) {
// Insert error marker and continue
const errorText = `%% AGENT-ERROR: Provider "${route.provider}" not available for ${annotation.tag} %%\n${annotation.originalText}`;
revisions.push({ annotation, revisedText: errorText });
flags.push({
type: "error",
line: annotation.lineStart,
description: `Provider "${route.provider}" not found for [${annotation.tag}]`,
});
hadErrors = true;
continue;
}
try {
// Determine token multiplier from the provider (defaults to 1.33 for Anthropic)
const tokenMultiplier = provider.tokenMultiplier ?? 1.33;
// Build context
const assembledCtx = assembleContext(
input.contextFiles,
annotation,
settings,
tokenMultiplier,
);
// Build prompt
const { system, user } = buildPrompt(
assembledCtx,
annotation,
settings.systemPromptTemplate,
);
// Only enable thinking for "heavy" tier tags on Anthropic provider
const tier: ComplexityTier = TAG_COMPLEXITY[annotation.tag] ?? "standard";
const useThinking = tier === "heavy" && route.provider === "anthropic";
// Call provider
const response = await callProvider(provider, {
systemPrompt: system,
userPrompt: user,
model: route.model,
maxTokens: settings.maxTokens ?? 16000,
apiKey: route.provider === "anthropic" ? settings.anthropicApiKey
: route.provider === "ollama" ? (settings.ollamaApiKey || undefined)
: undefined,
endpoint: route.provider === "ollama" ? settings.ollamaEndpoint : undefined,
useThinking,
});
revisions.push({ annotation, revisedText: response.text });
processedTags.push(annotation.tag);
// Track which context files were used
const contextUsed: string[] = ["chapter"];
if (assembledCtx.voiceTests) contextUsed.push("voiceTests");
if (assembledCtx.styleGuide) contextUsed.push("styleGuide");
if (assembledCtx.outline) contextUsed.push("outline");
if (assembledCtx.characters) contextUsed.push("characters");
if (assembledCtx.wiki) contextUsed.push("wiki");
const origWords = annotation.originalText.trim().split(/\s+/).filter((w) => w.length > 0).length;
const revisedWords = response.text.trim().split(/\s+/).filter((w) => w.length > 0).length;
changes.push({
tag: annotation.tag,
instruction: annotation.instruction,
lineStart: annotation.lineStart,
originalWordCount: origWords,
revisedWordCount: revisedWords,
contextFilesUsed: contextUsed,
provider: route.provider,
model: route.model,
});
} catch (err) {
const errMsg = err instanceof Error ? err.message : String(err);
const errorText = `%% AGENT-ERROR(${annotation.tag}): ${errMsg} %%\n${annotation.originalText}`;
revisions.push({ annotation, revisedText: errorText });
flags.push({
type: "error",
line: annotation.lineStart,
description: `API error for [${annotation.tag}]: ${errMsg}`,
});
hadErrors = true;
}
}
// (f) Assemble new version
const newVer = nextVersion(currentVersion);
const assemblyResult = assembleNewVersion(content, revisions, newVer);
const assembled = assemblyResult.content;
// Surface any skipped overlaps as review flags
for (const skip of assemblyResult.skippedOverlaps) {
flags.push({
type: "plot" as const,
line: skip.line,
description: `Overlapping annotation [${skip.tag}] at line ${skip.line} was skipped. Only the larger-scope annotation was processed.`,
});
}
// (g) Update frontmatter
const { frontmatter: assembledFm, body: assembledBody, keyOrder } = parseFrontmatter(assembled);
const wordCountAfter = countProseWords(assembled, settings.proseMarker);
const charsInScene = detectCharacters(assembled, typeof assembledFm.focus === "string" ? assembledFm.focus : "");
// Compute pending annotations remaining in the new version
const newAnnotations = parseAnnotations(assembled);
const pendingCount = newAnnotations.filter((a) => a.actionable).length;
const updatedFm = updateAgentFields(assembledFm, {
wordcount: wordCountAfter,
status: pendingCount > 0 ? "in-progress" : "revised",
agent_version: newVer,
agent_last_revised: new Date().toISOString(),
agent_annotations_pending: pendingCount,
characters_in_scene: charsInScene,
});
const newContent = serializeFrontmatter(updatedFm, assembledBody, keyOrder);
// (h) Update state
const newState = updateState(state, newVer, toProcess);
const stateJson = serializeState(newState);
// (i) Voice compliance check on the new prose
const voiceCompliance = checkVoiceCompliance(assembledBody);
// (j) Generate review note
const durationMs = Date.now() - startTime;
const reviewData = {
timestamp: new Date().toISOString(),
book: bookId,
chapter: chapterId,
version: newVer,
annotationsProcessed: changes.length,
annotationsSkipped: passthroughCount,
changes,
flags,
voiceCompliance,
wordCountBefore,
wordCountAfter,
};
const reviewContent = generateReview(reviewData);
// (k) Generate activity log entry
const logEntry = createLogEntry({
book: bookId,
chapter: chapterId,
versionFrom: currentVersion,
versionTo: newVer,
annotationsProcessed: changes.length,
annotationsSkipped: passthroughCount,
wordCountBefore,
wordCountAfter,
tags: processedTags,
durationMs,
provider: processedTags.length > 0 ? changes[0]?.provider ?? "unknown" : "none",
model: processedTags.length > 0 ? changes[0]?.model ?? "unknown" : "none",
voiceCompliance,
});
const logLine = formatLogEntry(logEntry);
return {
newContent,
newVersion: newVer,
stateJson,
reviewContent,
logLine,
annotationsProcessed: changes.length,
annotationsSkipped: passthroughCount,
tags: processedTags,
durationMs,
hadErrors,
};
}
+155
View File
@@ -0,0 +1,155 @@
/**
* PENNY - Per-Project Configuration
*
* 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.
*
* Parsed key-value pairs from the `## Project` section override
* global settings.
*/
import type { PennySettings } from "./types";
/** Map of PENNY.md keys to PennySettings field names. */
const KEY_MAP: Record<string, keyof PennySettings> = {
"drafts": "draftsFolder",
"style-guide": "styleGuide",
"voice-tests": "voiceTests",
"characters": "characterSheetsFolder",
"plot": "plotOutlinesFolder",
"wiki": "wikiFolder",
"reviews": "reviewsFolder",
"series-bible": "seriesBible",
"themes": "themesFile",
"context-budget": "contextBudget",
"max-tokens": "maxTokens",
};
/**
* Minimal vault interface so this module has no direct Obsidian dependency.
* The real Vault satisfies this shape.
*/
export interface VaultReader {
adapter: {
exists(path: string): Promise<boolean>;
read(path: string): Promise<string>;
};
}
/**
* 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.
*/
export async function findProjectConfig(
filePath: string,
vault: VaultReader,
): Promise<Partial<PennySettings> | null> {
// Guard: strip leading slash to prevent absolute adapter paths
const safePath = filePath.startsWith("/") ? filePath.slice(1) : filePath;
const parts = safePath.split("/");
// Remove the filename to start from the directory
parts.pop();
// Walk up directory tree
while (parts.length > 0) {
const candidate = parts.join("/") + "/PENNY.md";
try {
const exists = await vault.adapter.exists(candidate);
if (exists) {
const content = await vault.adapter.read(candidate);
return parseProjectConfig(content);
}
} catch {
// Skip unreadable paths
}
parts.pop();
}
// Check vault root
try {
const exists = await vault.adapter.exists("PENNY.md");
if (exists) {
const content = await vault.adapter.read("PENNY.md");
return parseProjectConfig(content);
}
} catch {
// Skip
}
return null;
}
/**
* Parse the `## Project` section from a PENNY.md file.
* Extracts simple `key: value` pairs and maps them to PennySettings fields.
*/
export function parseProjectConfig(content: string): Partial<PennySettings> {
const overrides: Partial<PennySettings> = {};
const lines = content.split("\n");
let inProjectSection = false;
for (const line of lines) {
// Detect section headings
if (/^##\s+/.test(line)) {
inProjectSection = /^##\s+Project\b/i.test(line);
continue;
}
if (!inProjectSection) continue;
// Parse `key: value` pairs
const kvMatch = line.match(/^([a-z][a-z0-9-]*)\s*:\s*(.+)$/i);
if (!kvMatch) continue;
const rawKey = kvMatch[1].toLowerCase().trim();
const rawValue = kvMatch[2].trim();
const settingsKey = KEY_MAP[rawKey];
if (!settingsKey) continue;
// Type-coerce based on the target field
if (settingsKey === "contextBudget" || settingsKey === "maxTokens") {
const num = parseInt(rawValue, 10);
if (!isNaN(num) && num > 0) {
(overrides as Record<string, unknown>)[settingsKey] = num;
}
} else {
(overrides as Record<string, unknown>)[settingsKey] = rawValue;
}
}
// Also parse voice rules section
let inVoiceSection = false;
const voiceSection: string[] = [];
for (const line of lines) {
if (/^##\s+/.test(line)) {
inVoiceSection = /^##\s+Voice\s+Rules\b/i.test(line);
continue;
}
if (inVoiceSection) {
voiceSection.push(line);
}
}
// Filter out multi-line HTML comments from the voice section
const voiceLines: string[] = [];
let inComment = false;
for (const line of voiceSection) {
if (line.trim().startsWith("<!--")) inComment = true;
if (inComment) {
if (line.trim().endsWith("-->")) inComment = false;
continue;
}
voiceLines.push(line);
}
const voiceRulesText = voiceLines.join("\n").trim();
if (voiceRulesText) {
overrides.customVoiceRules = voiceRulesText;
}
return overrides;
}
+221
View File
@@ -0,0 +1,221 @@
/**
* PENNY - Anthropic Claude Provider
*
* Implements LLMService for the Anthropic Messages API.
* Uses an injected HTTP function (Obsidian's requestUrl) so this module
* has no direct Obsidian API dependency.
*/
import type {
LLMService,
ModelInfo,
CompletionRequest,
CompletionResponse,
ProviderSettings,
HttpFn,
} from "./service";
const ANTHROPIC_API_URL = "https://api.anthropic.com/v1/messages";
const ANTHROPIC_VERSION = "2023-06-01";
/** Models that support the extended thinking feature. */
const THINKING_CAPABLE = new Set(["claude-opus-4-6", "claude-sonnet-4-6"]);
/**
* Static model catalog for Anthropic.
*
* 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.
*/
export const ANTHROPIC_MODELS: ModelInfo[] = [
{
id: "claude-opus-4-6",
name: "Claude Opus 4.6",
contextWindow: 200000,
costPer1kInput: 0.015,
costPer1kOutput: 0.075,
},
{
id: "claude-sonnet-4-6",
name: "Claude Sonnet 4.6",
contextWindow: 200000,
costPer1kInput: 0.003,
costPer1kOutput: 0.015,
},
{
id: "claude-haiku-4-5",
name: "Claude Haiku 4.5",
contextWindow: 200000,
costPer1kInput: 0.0008,
costPer1kOutput: 0.004,
},
];
/** Token estimation multiplier: ~1.33 tokens per word for Anthropic models. */
const TOKEN_MULTIPLIER = 1.33;
export class AnthropicProvider implements LLMService {
readonly name = "anthropic";
readonly requiresApiKey = true;
constructor(private httpFn: HttpFn) {}
async getModels(): Promise<ModelInfo[]> {
return ANTHROPIC_MODELS;
}
async complete(request: CompletionRequest): Promise<CompletionResponse> {
const apiKey = request.apiKey;
if (!apiKey) {
throw new Error("Anthropic API key is required");
}
const body: Record<string, unknown> = {
model: request.model,
max_tokens: request.maxTokens,
system: request.systemPrompt,
messages: [
{ role: "user", content: request.userPrompt },
],
};
// Use adaptive thinking (recommended for Claude 4.6+).
// budget_tokens is deprecated on Opus 4.6 and Sonnet 4.6.
if (request.useThinking === true && THINKING_CAPABLE.has(request.model)) {
body.thinking = { type: "adaptive" };
}
const response = await this.httpFn({
url: ANTHROPIC_API_URL,
method: "POST",
headers: {
"x-api-key": apiKey,
"anthropic-version": ANTHROPIC_VERSION,
"content-type": "application/json",
},
body: JSON.stringify(body),
});
if (response.status < 200 || response.status >= 300) {
throw this.buildHttpError(response.status, response.text);
}
return this.parseResponse(response.text, request.model);
}
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 "Anthropic API key is required";
}
try {
const body = {
model: "claude-haiku-4-5",
max_tokens: 16,
messages: [{ role: "user", content: "ping" }],
};
const response = await this.httpFn({
url: ANTHROPIC_API_URL,
method: "POST",
headers: {
"x-api-key": settings.apiKey,
"anthropic-version": ANTHROPIC_VERSION,
"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).message;
} catch (err: unknown) {
const message = err instanceof Error ? err.message : String(err);
return `Anthropic connection failed: ${message}`;
}
}
/**
* Build a descriptive Error from an Anthropic HTTP error response.
* Attempts to parse the Anthropic error JSON format; falls back to raw text.
*/
private buildHttpError(status: number, responseText: string): Error {
let detail: string;
try {
const data = JSON.parse(responseText);
if (data?.error?.message) {
detail = data.error.message;
} else {
detail = responseText;
}
} catch {
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}`);
}
/**
* Parse an Anthropic Messages API response.
*
* Response shape:
* ```json
* {
* "content": [
* { "type": "thinking", "thinking": "..." },
* { "type": "text", "text": "the actual output" }
* ],
* "usage": { "input_tokens": 100, "output_tokens": 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("Anthropic API returned malformed response: " + responseText.slice(0, 200));
}
throw err;
}
let text = "";
if (data && Array.isArray(data.content)) {
const textBlock = data.content.find(
(block: Record<string, unknown>) => block.type === "text",
);
if (textBlock && typeof textBlock.text === "string") {
text = textBlock.text.trim();
}
}
const usage = data.usage
? {
inputTokens: data.usage.input_tokens as number | undefined,
outputTokens: data.usage.output_tokens as number | undefined,
}
: undefined;
return {
text,
usage,
model,
provider: this.name,
};
}
}
+65
View File
@@ -0,0 +1,65 @@
/**
* 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.
*/
// Service interface and types
export type {
LLMService,
ModelInfo,
CompletionRequest,
CompletionResponse,
ProviderSettings,
HttpFn,
HttpRequestParam,
HttpResponse,
} from "./service";
// Router
export {
TAG_COMPLEXITY,
getRoute,
} from "./router";
export type {
ComplexityTier,
RouteConfig,
ResolvedRoute,
} from "./router";
// Registry
export { ProviderRegistry } from "./registry";
// Provider implementations
export { AnthropicProvider, ANTHROPIC_MODELS } from "./anthropic";
export { OllamaProvider } from "./ollama";
// -- Convenience factory --
import type { HttpFn } from "./service";
import { ProviderRegistry } from "./registry";
import { AnthropicProvider } from "./anthropic";
import { OllamaProvider } from "./ollama";
/**
* Create a fresh ProviderRegistry pre-populated with all built-in providers.
*
* The plugin shell calls this at startup, passing Obsidian's `requestUrl`
* (adapted to the HttpFn signature) so providers can make HTTP calls
* without importing Obsidian directly.
*
* ```ts
* import { requestUrl } from "obsidian";
* import { createRegistry } from "./providers";
*
* const reg = createRegistry((params) => requestUrl(params));
* ```
*/
export function createRegistry(httpFn: HttpFn): ProviderRegistry {
const reg = new ProviderRegistry();
reg.register(new AnthropicProvider(httpFn));
reg.register(new OllamaProvider(httpFn));
return reg;
}
+223
View File
@@ -0,0 +1,223 @@
/**
* 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.
*
* Ollama exposes two relevant APIs:
* - /api/tags -- list locally available models
* - /v1/chat/completions -- OpenAI-compatible chat completions
*/
import type {
LLMService,
ModelInfo,
CompletionRequest,
CompletionResponse,
ProviderSettings,
HttpFn,
} from "./service";
const DEFAULT_OLLAMA_ENDPOINT = "http://localhost:11434";
/**
* Token estimation multiplier: ~1.0 tokens per word for most local models.
*
* Ollama models are fetched dynamically via /api/tags, so there is no
* hardcoded model catalog here (unlike the Anthropic provider).
*/
const TOKEN_MULTIPLIER = 1.0;
export class OllamaProvider implements LLMService {
readonly name = "ollama";
readonly requiresApiKey = false;
constructor(private httpFn: HttpFn) {}
/**
* Fetch available models from the Ollama instance.
* Calls GET /api/tags and returns the list of locally installed models.
*/
async getModels(settings?: ProviderSettings): Promise<ModelInfo[]> {
const endpoint = this.resolveEndpoint(settings);
try {
const response = await this.httpFn({
url: `${endpoint}/api/tags`,
method: "GET",
});
if (response.status < 200 || response.status >= 300) {
throw this.buildHttpError(response.status, response.text, endpoint);
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let data: any;
try {
data = JSON.parse(response.text);
} catch (err) {
if (err instanceof SyntaxError) {
throw new Error("Ollama API returned malformed response: " + response.text.slice(0, 200));
}
throw err;
}
if (data && Array.isArray(data.models)) {
return data.models.map((m: Record<string, unknown>) => ({
id: String(m.name ?? m.model ?? "unknown"),
name: String(m.name ?? m.model ?? "unknown"),
contextWindow: undefined,
costPer1kInput: 0,
costPer1kOutput: 0,
}));
}
return [];
} catch (err) {
if (err instanceof Error && err.message.startsWith("Ollama error")) {
throw err;
}
const message = err instanceof Error ? err.message : String(err);
if (message.includes("ECONNREFUSED") || message.includes("fetch failed") || message.includes("Connection refused")) {
throw new Error(`Cannot connect to Ollama at ${endpoint}. Is Ollama running?`);
}
// For other errors (network issues, etc.), return empty list.
return [];
}
}
async complete(request: CompletionRequest): Promise<CompletionResponse> {
const endpoint = request.endpoint ?? DEFAULT_OLLAMA_ENDPOINT;
const body = {
model: request.model,
messages: [
{ role: "system", content: request.systemPrompt },
{ role: "user", content: request.userPrompt },
],
max_tokens: request.maxTokens,
stream: false,
};
const headers: Record<string, string> = {
"content-type": "application/json",
};
if (request.apiKey) {
headers["Authorization"] = `Bearer ${request.apiKey}`;
}
let response;
try {
response = await this.httpFn({
url: `${endpoint}/v1/chat/completions`,
method: "POST",
headers,
body: JSON.stringify(body),
});
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
if (message.includes("ECONNREFUSED") || message.includes("fetch failed") || message.includes("Connection refused")) {
throw new Error(`Cannot connect to Ollama at ${endpoint}. Is Ollama running?`);
}
throw err;
}
if (response.status < 200 || response.status >= 300) {
throw this.buildHttpError(response.status, response.text, endpoint);
}
return this.parseResponse(response.text, request.model);
}
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> {
const endpoint = this.resolveEndpoint(settings);
try {
const response = await this.httpFn({
url: `${endpoint}/api/tags`,
method: "GET",
});
if (response.status >= 200 && response.status < 300) {
return null; // success
}
return this.buildHttpError(response.status, response.text, endpoint).message;
} catch (err: unknown) {
const message = err instanceof Error ? err.message : String(err);
if (message.includes("ECONNREFUSED") || message.includes("fetch failed") || message.includes("Connection refused")) {
return `Cannot connect to Ollama at ${endpoint}. Is Ollama running?`;
}
return `Ollama connection failed: ${message}. Is Ollama running at ${endpoint}?`;
}
}
/**
* Build a descriptive Error from an Ollama HTTP error response.
*/
private buildHttpError(status: number, responseText: string, _endpoint: string): Error {
let detail: string;
try {
const data = JSON.parse(responseText);
detail = data?.error ?? data?.message ?? responseText;
} catch {
detail = responseText;
}
return new Error(`Ollama error (${status}): ${detail}`);
}
/**
* Parse an OpenAI-compatible chat completions response.
*
* Response shape:
* ```json
* {
* "choices": [
* { "message": { "role": "assistant", "content": "response text" } }
* ],
* "usage": { "prompt_tokens": 100, "completion_tokens": 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("Ollama 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.trim();
}
}
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,
};
}
private resolveEndpoint(settings?: ProviderSettings): string {
return (settings?.endpoint as string) ?? DEFAULT_OLLAMA_ENDPOINT;
}
}
+33
View File
@@ -0,0 +1,33 @@
/**
* 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.
*/
import type { LLMService } from "./service";
export class ProviderRegistry {
private providers: Map<string, LLMService> = new Map();
/** Register a provider. Overwrites any existing provider with the same name. */
register(provider: LLMService): void {
this.providers.set(provider.name, provider);
}
/** Get a provider by name. Returns undefined if not registered. */
get(name: string): LLMService | undefined {
return this.providers.get(name);
}
/** Get all registered providers. */
getAll(): LLMService[] {
return Array.from(this.providers.values());
}
/** Get names of all registered providers. */
getNames(): string[] {
return Array.from(this.providers.keys());
}
}
+77
View File
@@ -0,0 +1,77 @@
/**
* PENNY - Model Router
*
* 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.
*
* Unknown tags fall back to "standard".
*/
/** Complexity tiers that map to model slots. */
export type ComplexityTier = "light" | "standard" | "heavy";
/** Maps annotation tags to their complexity tier. */
export const TAG_COMPLEXITY: Record<string, ComplexityTier> = {
CUT: "light",
PACING: "light",
TONE: "standard",
EXPAND: "standard",
PLOT: "standard",
REWRITE: "heavy",
DIALOG: "heavy",
CHARACTER: "heavy",
};
/** User-configured model assignments per complexity tier. */
export interface RouteConfig {
light: { provider: string; model: string };
standard: { provider: string; model: string };
heavy: { provider: string; model: string };
}
/** A resolved route: which provider and model to use. */
export interface ResolvedRoute {
provider: string;
model: string;
}
/**
* Resolve a tag to a provider + model.
*
* 1. Look up the tag in TAG_COMPLEXITY to get a tier.
* 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.
*/
export function getRoute(tag: string, config: RouteConfig): ResolvedRoute {
const tier: ComplexityTier = TAG_COMPLEXITY[tag] ?? "standard";
const route = config[tier];
return {
provider: route.provider,
model: resolveModel(route.model, tier),
};
}
/**
* Map the special "auto-latest" model identifier to the recommended
* concrete model for the given complexity tier.
*
* 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.
*/
export function resolveModel(model: string, tier: string): string {
if (model === "auto-latest") {
switch (tier) {
case "light": return "claude-haiku-4-5";
case "standard": return "claude-sonnet-4-6";
case "heavy": return "claude-opus-4-6";
default: return "claude-sonnet-4-6";
}
}
return model;
}
+80
View File
@@ -0,0 +1,80 @@
/**
* PENNY - LLM Service Interface
*
* Every LLM provider implements this interface. Adding a new provider means
* implementing LLMService and registering it with the ProviderRegistry.
*/
/** HTTP request parameters -- mirrors Obsidian's RequestUrlParam shape. */
export interface HttpRequestParam {
url: string;
method?: string;
headers?: Record<string, string>;
body?: string;
contentType?: string;
}
/** HTTP response shape -- mirrors Obsidian's RequestUrlResponse. */
export interface HttpResponse {
status: number;
headers: Record<string, string>;
text: string;
json: unknown;
}
/** Injectable HTTP function type (Obsidian's requestUrl or a test mock). */
export type HttpFn = (params: HttpRequestParam) => Promise<HttpResponse>;
/** Every LLM provider implements this interface. */
export interface LLMService {
readonly name: string;
readonly requiresApiKey: boolean;
/** Available models for this provider (static list or fetched from endpoint). */
getModels(settings?: ProviderSettings): Promise<ModelInfo[]>;
/** Send a prompt and get a response. */
complete(request: CompletionRequest): Promise<CompletionResponse>;
/** Rough token estimate for this provider's tokenizer. */
estimateTokens(text: string): number;
/** Test connectivity. Returns error message or null if OK. */
testConnection(settings: ProviderSettings): Promise<string | null>;
}
/** Metadata about a model available from a provider. */
export interface ModelInfo {
id: string;
name: string;
contextWindow?: number;
costPer1kInput?: number;
costPer1kOutput?: number;
}
/** Request sent to an LLM provider. */
export interface CompletionRequest {
systemPrompt: string;
userPrompt: string;
model: string;
maxTokens: number;
apiKey?: string;
endpoint?: string;
/** When true AND the model supports it, enable extended thinking. */
useThinking?: boolean;
}
/** Response from an LLM provider. */
export interface CompletionResponse {
text: string;
usage?: { inputTokens?: number; outputTokens?: number };
model: string;
provider: string;
}
/** Per-provider settings (API keys, endpoints, etc.). */
export interface ProviderSettings {
apiKey?: string;
endpoint?: string;
[key: string]: unknown;
}
+633 -33
View File
@@ -5,9 +5,11 @@
* voice rules, behavior, and git integration.
*/
import { PluginSettingTab, Setting, App } from "obsidian";
import { PluginSettingTab, Setting, App, Notice, TFolder, TFile, FuzzySuggestModal } from "obsidian";
import type PennyPlugin from "./main";
import { DEFAULT_SYSTEM_PROMPT } from "./types";
import { ANTHROPIC_MODELS } from "./providers/anthropic";
import { resolveModel } from "./providers/router";
/**
* Settings tab for the PENNY plugin.
@@ -18,6 +20,8 @@ import { DEFAULT_SYSTEM_PROMPT } from "./types";
*/
export class PennySettingTab extends PluginSettingTab {
plugin: PennyPlugin;
/** Cached Ollama model list, populated on settings tab open */
private ollamaModels: Array<{ id: string; name: string }> = [];
constructor(app: App, plugin: PennyPlugin) {
super(app, plugin);
@@ -28,62 +32,255 @@ export class PennySettingTab extends PluginSettingTab {
const { containerEl } = this;
containerEl.empty();
containerEl.createEl("h1", { text: "PENNY - Prose Co-Author" });
containerEl.createEl("p", {
text: "Configure PENNY to work with your novel project. Set your API key, point PENNY at your project files, and define your voice rules.",
// Plugin header
const header = containerEl.createDiv({ cls: "penny-settings-header" });
const titleRow = header.createDiv({ cls: "penny-title-row" });
titleRow.createEl("h1", { text: "PENNY" });
titleRow.createEl("span", { text: `v${this.plugin.manifest.version}`, cls: "penny-version-badge" });
header.createEl("p", {
text: "Prose Engine for Narrative, Notes, and Yarns",
cls: "penny-settings-subtitle",
});
header.createEl("p", {
text: "AI-powered prose revision agent. Processes editorial annotations, enforces voice consistency, and manages chapter versions.",
cls: "setting-item-description",
});
this.renderApiSection(containerEl);
const links = header.createDiv({ cls: "penny-settings-links" });
const addLink = (label: string, url: string) => {
const a = links.createEl("a", { text: label, href: url });
a.setAttr("target", "_blank");
};
addLink("Documentation", "https://github.com/KofTwentyTwo/obsidian-penny/tree/main/docs");
links.createSpan({ text: " | " });
addLink("Getting Started", "https://github.com/KofTwentyTwo/obsidian-penny/blob/main/docs/getting-started.md");
links.createSpan({ text: " | " });
addLink("Report a Bug", "https://github.com/KofTwentyTwo/obsidian-penny/issues");
links.createSpan({ text: " | " });
addLink("GitHub", "https://github.com/KofTwentyTwo/obsidian-penny");
containerEl.createEl("hr");
this.renderProvidersSection(containerEl);
this.renderModelRoutingSection(containerEl);
this.renderProjectStructureSection(containerEl);
this.renderVoiceSection(containerEl);
this.renderBehaviorSection(containerEl);
this.renderGitSection(containerEl);
this.renderHelpSection(containerEl);
}
/**
* API Settings -- key, model, context budget.
* Providers -- Anthropic key, Ollama endpoint, test buttons.
*/
private renderApiSection(containerEl: HTMLElement): void {
private renderProvidersSection(containerEl: HTMLElement): void {
const details = containerEl.createEl("details", { attr: { open: "" } });
details.createEl("summary", { text: "API Settings" });
details.createEl("summary", { text: "Providers" });
details.createEl("p", {
text: "Configure one or more LLM providers. PENNY can route different annotation types to different providers.",
cls: "setting-item-description",
});
// -- Anthropic --
details.createEl("h4", { text: "Anthropic (Claude API)" });
new Setting(details)
.setName("API key")
.setName("Anthropic API key")
.setDesc(
"Your Anthropic API key. Get one at console.anthropic.com. Stored locally in plugin data."
)
.addText((text) =>
text
.setPlaceholder("sk-ant-...")
.setValue(this.plugin.settings.apiKey)
.setValue(this.plugin.settings.anthropicApiKey)
.then((t) => {
t.inputEl.type = "password";
t.inputEl.style.width = "300px";
})
.onChange(async (value) => {
this.plugin.settings.apiKey = value.trim();
this.plugin.settings.anthropicApiKey = value.trim();
await this.plugin.saveSettings();
})
);
// Anthropic models -- show static list + fetch from API if key is set
const anthropicModelsSetting = new Setting(details)
.setName("Available Claude models")
.setDesc("Models available for use with Anthropic.");
const anthropicModelListEl = details.createEl("div", { cls: "penny-model-list" });
// Auto-load if API key is set
this.loadAnthropicModels(anthropicModelListEl);
new Setting(details)
.setName("Test Anthropic 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("anthropic");
if (!provider) throw new Error("Anthropic provider not registered");
const result = await provider.testConnection({
apiKey: this.plugin.settings.anthropicApiKey,
});
if (result) {
new Notice(
`PENNY: Anthropic connection failed -- ${result}`,
6000,
);
} else {
new Notice(
"PENNY: Anthropic connection OK.",
4000,
);
}
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
new Notice(
`PENNY: Test failed -- ${msg}`,
6000,
);
} finally {
button.setButtonText("Test Connection");
button.setDisabled(false);
}
})
);
// -- Ollama --
details.createEl("h4", { text: "Ollama (Local Models)" });
new Setting(details)
.setName("Ollama endpoint")
.setDesc(
"URL of the Ollama server. Default: http://localhost:11434. Change only if Ollama runs on a different host or port."
)
.addText((text) =>
text
.setPlaceholder("http://localhost:11434")
.setValue(this.plugin.settings.ollamaEndpoint)
.then((t) => {
t.inputEl.style.width = "300px";
})
.onChange(async (value) => {
this.plugin.settings.ollamaEndpoint = value.trim();
await this.plugin.saveSettings();
})
);
new Setting(details)
.setName("Model")
.setName("Ollama API key (optional)")
.setDesc(
"Claude model for revisions. Opus is the most capable. Sonnet is faster and cheaper for routine work."
"Only needed if your Ollama instance requires authentication (e.g. remote hosted). Leave blank for local Ollama."
)
.addDropdown((dropdown) =>
dropdown
.addOption("claude-opus-4-6", "Claude Opus 4.6")
.addOption("claude-sonnet-4-6", "Claude Sonnet 4.6")
.addOption("claude-haiku-4-5", "Claude Haiku 4.5")
.setValue(this.plugin.settings.model)
.addText((text) =>
text
.setPlaceholder("")
.setValue(this.plugin.settings.ollamaApiKey)
.then((t) => {
t.inputEl.type = "password";
t.inputEl.style.width = "300px";
})
.onChange(async (value) => {
this.plugin.settings.model = value;
this.plugin.settings.ollamaApiKey = value.trim();
await this.plugin.saveSettings();
})
);
// Ollama models -- fetch and display
const ollamaModelListEl = details.createEl("div", { cls: "penny-model-list" });
new Setting(details)
.setName("Available Ollama models")
.setDesc(
"Models installed on your Ollama instance. Click Refresh to fetch the latest list."
)
.addButton((button) =>
button.setButtonText("Refresh Models").onClick(async () => {
await this.loadOllamaModels(ollamaModelListEl);
})
);
// Auto-load on render
this.loadOllamaModels(ollamaModelListEl);
new Setting(details)
.setName("Test Ollama connection")
.setDesc("Verify that Ollama is reachable and list available models.")
.addButton((button) =>
button.setButtonText("Test Connection").onClick(async () => {
button.setButtonText("Testing...");
button.setDisabled(true);
try {
const provider = this.plugin.providerRegistry.get("ollama");
if (!provider) throw new Error("Ollama provider not registered");
const result = await provider.testConnection({
endpoint: this.plugin.settings.ollamaEndpoint,
apiKey: this.plugin.settings.ollamaApiKey,
});
if (result) {
new Notice(
`PENNY: Ollama test failed -- ${result}`,
6000,
);
} else {
new Notice(
"PENNY: Ollama connection OK.",
4000,
);
}
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
new Notice(
`PENNY: Ollama test failed -- ${msg}`,
6000,
);
} finally {
button.setButtonText("Test Connection");
button.setDisabled(false);
}
})
);
}
/**
* Model Routing -- map complexity tiers to provider+model.
*/
private renderModelRoutingSection(containerEl: HTMLElement): void {
const details = containerEl.createEl("details", { attr: { open: "" } });
details.createEl("summary", { text: "Model Routing" });
details.createEl("p", {
text: "Choose which provider and model to use for each complexity tier. Simple tasks (CUT, PACING) use Light. Complex tasks (REWRITE, DIALOG, CHARACTER) use Heavy. Everything else uses Standard.",
cls: "setting-item-description",
});
const providers = this.plugin.providerRegistry.getAll();
const providerNames = providers.map((p) => p.name);
new Setting(details)
.setName("Use same model for all tiers")
.setDesc(
"When on, all annotations use the Standard model. Turn off to assign different models per complexity tier."
)
.addToggle((toggle) =>
toggle
.setValue(this.plugin.settings.useSameModelForAll)
.onChange(async (value) => {
this.plugin.settings.useSameModelForAll = value;
await this.plugin.saveSettings();
this.display(); // Re-render to show/hide per-tier rows
})
);
if (this.plugin.settings.useSameModelForAll) {
// Single row -- edits routeStandard and mirrors to light/heavy
this.renderRouteRow(details, "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);
}
// Context budget (moved here from old API section since it relates to routing)
new Setting(details)
.setName("Context budget")
.setDesc(
@@ -103,6 +300,108 @@ export class PennySettingTab extends PluginSettingTab {
);
}
/**
* Render a single route row: provider dropdown + model selector.
*
* For Anthropic the model is a dropdown. For Ollama it is a text input
* (the user types the model name, e.g. "llama3.2").
*/
private renderRouteRow(
container: HTMLElement,
label: string,
routeKey: "routeLight" | "routeStandard" | "routeHeavy",
providerNames: string[],
mirrorAll = false,
): void {
const route = this.plugin.settings[routeKey];
const anthropicModels = ANTHROPIC_MODELS.map((m) => ({ id: m.id, name: m.name }));
const setting = new Setting(container).setName(label);
// Provider dropdown
setting.addDropdown((dropdown) => {
for (const name of providerNames) {
dropdown.addOption(name, name.charAt(0).toUpperCase() + name.slice(1));
}
dropdown.setValue(route.provider);
dropdown.onChange(async (value) => {
route.provider = value;
// When switching to Ollama, keep model text; switching to Anthropic, default to sonnet
if (value === "anthropic" && !anthropicModels.some((m) => m.id === route.model)) {
route.model = "claude-sonnet-4-6";
}
if (mirrorAll) {
this.plugin.settings.routeLight = { ...route };
this.plugin.settings.routeHeavy = { ...route };
}
await this.plugin.saveSettings();
this.display(); // Re-render to swap model widget
});
});
// Model selector: dropdown for Anthropic, text input for Ollama
if (route.provider === "anthropic") {
// Determine which tier this route represents (for display purposes)
const tierLabel = routeKey === "routeLight" ? "light"
: routeKey === "routeHeavy" ? "heavy"
: "standard";
const autoResolvedModel = resolveModel("auto-latest", tierLabel);
const autoLabel = `Auto (recommended) -> ${autoResolvedModel}`;
setting.addDropdown((dropdown) => {
dropdown.addOption("auto-latest", autoLabel);
for (const m of anthropicModels) {
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) {
setting.addDropdown((dropdown) => {
dropdown.addOption("", "Select a model...");
for (const m of this.ollamaModels) {
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 {
// Fallback: text input when Ollama models haven't been fetched yet
setting.addText((text) =>
text
.setPlaceholder("e.g. llama3.2, mistral, deepseek-coder")
.setValue(route.model)
.then((t) => { t.inputEl.style.width = "250px"; })
.onChange(async (value) => {
route.model = value.trim();
if (mirrorAll) {
this.plugin.settings.routeLight = { ...route };
this.plugin.settings.routeHeavy = { ...route };
}
await this.plugin.saveSettings();
})
);
}
}
}
/**
* Project Structure -- paths to drafts, style guide, characters, etc.
*/
@@ -114,6 +413,24 @@ export class PennySettingTab extends PluginSettingTab {
cls: "setting-item-description",
});
// Action buttons row
const buttonsRow = new Setting(details)
.setName("Project detection")
.setDesc("Scan the vault for common novel project folder patterns, or validate that configured paths exist.");
buttonsRow.addButton((button) =>
button.setButtonText("Detect structure").onClick(async () => {
this.detectProjectStructure();
})
);
buttonsRow.addButton((button) =>
button
.setButtonText("Validate paths")
.onClick(async () => {
this.validateProjectPaths();
})
);
const pathSettings: Array<{
key: keyof typeof this.plugin.settings;
name: string;
@@ -184,7 +501,10 @@ export class PennySettingTab extends PluginSettingTab {
for (const ps of pathSettings) {
const key = ps.key as string;
new Setting(details)
const isFolder = ps.name.toLowerCase().includes("folder");
let textInput: HTMLInputElement | null = null;
const s = new Setting(details)
.setName(ps.name)
.setDesc(ps.desc)
.addText((text) =>
@@ -195,12 +515,39 @@ export class PennySettingTab extends PluginSettingTab {
(this.plugin.settings as unknown as Record<string, string>)[key]
)
)
.then((t) => { textInput = t.inputEl; })
.onChange(async (value) => {
(this.plugin.settings as unknown as Record<string, string>)[key] =
value.trim();
await this.plugin.saveSettings();
})
);
// Add a browse button that opens a folder/file picker
s.addButton((button) =>
button.setButtonText("Browse").onClick(() => {
if (isFolder) {
const folders = this.plugin.app.vault.getAllLoadedFiles()
.filter((f): f is TFolder => f instanceof TFolder)
.map((f) => f.path)
.sort();
new PathSuggestModal(this.app, folders, async (chosen) => {
(this.plugin.settings as unknown as Record<string, string>)[key] = chosen;
await this.plugin.saveSettings();
if (textInput) textInput.value = chosen;
}).open();
} else {
const files = this.plugin.app.vault.getMarkdownFiles()
.map((f) => f.path)
.sort();
new PathSuggestModal(this.app, files, async (chosen) => {
(this.plugin.settings as unknown as Record<string, string>)[key] = chosen;
await this.plugin.saveSettings();
if (textInput) textInput.value = chosen;
}).open();
}
})
);
}
}
@@ -211,7 +558,7 @@ export class PennySettingTab extends PluginSettingTab {
const details = containerEl.createEl("details");
details.createEl("summary", { text: "Voice Rules" });
new Setting(details)
const voiceRulesSetting = new Setting(details)
.setName("Custom voice rules")
.setDesc(
"Project-specific voice rules injected into every prompt. Write the rules that matter most for your project's voice. These go into the CRITICAL VOICE RULES section of the processing prompt."
@@ -222,17 +569,21 @@ export class PennySettingTab extends PluginSettingTab {
"e.g., No internal monologue. She never thinks in narrated sentences..."
)
.setValue(this.plugin.settings.customVoiceRules)
.then((t) => {
t.inputEl.rows = 8;
t.inputEl.style.width = "100%";
})
.onChange(async (value) => {
this.plugin.settings.customVoiceRules = value;
await this.plugin.saveSettings();
})
);
// Make the textarea full-width below the label
voiceRulesSetting.settingEl.style.display = "block";
const voiceTextarea = voiceRulesSetting.settingEl.querySelector("textarea");
if (voiceTextarea) {
voiceTextarea.rows = 10;
voiceTextarea.style.width = "100%";
voiceTextarea.style.marginTop = "8px";
}
new Setting(details)
const systemPromptSetting = new Setting(details)
.setName("System prompt template")
.setDesc(
"The full system prompt sent with every revision request. Uses {voice_rules}, {voice_tests}, {style_guide}, {outline}, {characters}, {wiki}, {chapter}, {tag}, {passage}, {instruction} placeholders. Advanced users only."
@@ -240,17 +591,21 @@ export class PennySettingTab extends PluginSettingTab {
.addTextArea((text) =>
text
.setValue(this.plugin.settings.systemPromptTemplate)
.then((t) => {
t.inputEl.rows = 12;
t.inputEl.style.width = "100%";
t.inputEl.style.fontFamily = "monospace";
t.inputEl.style.fontSize = "12px";
})
.onChange(async (value) => {
this.plugin.settings.systemPromptTemplate = value;
await this.plugin.saveSettings();
})
);
// Make the textarea full-width below the label
systemPromptSetting.settingEl.style.display = "block";
const sysTextarea = systemPromptSetting.settingEl.querySelector("textarea");
if (sysTextarea) {
sysTextarea.rows = 16;
sysTextarea.style.width = "100%";
sysTextarea.style.marginTop = "8px";
sysTextarea.style.fontFamily = "var(--font-monospace)";
sysTextarea.style.fontSize = "12px";
}
new Setting(details)
.setName("Reset system prompt")
@@ -333,6 +688,181 @@ export class PennySettingTab extends PluginSettingTab {
);
}
/**
* Detect project structure by scanning the vault for common patterns.
*/
/**
* Load and display Anthropic models in the given container element.
*/
private async loadAnthropicModels(containerEl: HTMLElement): Promise<void> {
containerEl.empty();
// Show static catalog first
for (const m of ANTHROPIC_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",
});
}
}
}
/**
* Fetch and display Ollama models in the given container element.
*/
private async loadOllamaModels(containerEl: HTMLElement): Promise<void> {
containerEl.empty();
containerEl.createEl("div", { text: "Loading...", cls: "penny-model-meta" });
try {
const provider = this.plugin.providerRegistry.get("ollama");
if (!provider) {
containerEl.empty();
containerEl.createEl("div", { text: "Ollama provider not available.", cls: "penny-model-meta" });
return;
}
const models = await provider.getModels({
endpoint: this.plugin.settings.ollamaEndpoint,
apiKey: this.plugin.settings.ollamaApiKey,
});
// Cache for use in route dropdowns
this.ollamaModels = models.map((m) => ({ id: m.id, name: m.name }));
containerEl.empty();
if (models.length === 0) {
containerEl.createEl("div", {
text: "No models found. Install models with: ollama pull <model>",
cls: "penny-model-meta",
});
} else {
for (const m of 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})` });
}
}
} catch (err) {
containerEl.empty();
const msg = err instanceof Error ? err.message : String(err);
containerEl.createEl("div", {
text: `Could not connect to Ollama: ${msg}`,
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[];
const allFiles = vault.getAllLoadedFiles().filter((f) => f instanceof TFile) as TFile[];
const folderPaths = allFolders.map((f) => f.path);
const filePaths = allFiles.map((f) => f.path);
// Detection patterns: key -> { type: "folder"|"file", patterns: string[] }
const detections: Array<{
key: string;
type: "folder" | "file";
patterns: RegExp[];
label: string;
}> = [
{ key: "draftsFolder", type: "folder", patterns: [/^(.*\/)?04-drafts$/], label: "Drafts folder" },
{ key: "styleGuide", type: "file", patterns: [/^(.*\/)?06-reference\/style-guide\.md$/], label: "Style guide" },
{ key: "voiceTests", type: "file", patterns: [/^(.*\/)?06-reference\/voice-tests\.md$/], label: "Voice tests" },
{ key: "characterSheetsFolder", type: "folder", patterns: [/^(.*\/)?02-characters$/], label: "Character sheets" },
{ key: "plotOutlinesFolder", type: "folder", patterns: [/^(.*\/)?03-plot$/], label: "Plot outlines" },
{ key: "wikiFolder", type: "folder", patterns: [/^(.*\/)?05-wiki$/], label: "Wiki folder" },
{ key: "seriesBible", type: "file", patterns: [/^(.*\/)?00-series\/series-bible\.md$/], label: "Series bible" },
{ key: "themesFile", type: "file", patterns: [/^(.*\/)?00-series\/themes\.md$/], label: "Themes file" },
{ key: "reviewsFolder", type: "folder", patterns: [/^(.*\/)?07-reviews$/], label: "Reviews folder" },
];
let detectedCount = 0;
const detected: string[] = [];
for (const det of detections) {
const searchPaths = det.type === "folder" ? folderPaths : filePaths;
for (const pattern of det.patterns) {
const match = searchPaths.find((p) => pattern.test(p));
if (match) {
const settings = this.plugin.settings as unknown as Record<string, string>;
// Only fill if currently empty
if (!settings[det.key]) {
settings[det.key] = match;
detectedCount++;
detected.push(`${det.label}: ${match}`);
}
break;
}
}
}
if (detectedCount > 0) {
await this.plugin.saveSettings();
new Notice(
`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.
} else {
new Notice(
"PENNY: No project structure detected, or all paths are already configured.",
6000,
);
}
}
/**
* Validate that all configured project paths exist in the vault.
*/
private validateProjectPaths(): void {
const vault = this.plugin.app.vault;
const settings = this.plugin.settings;
const checks: Array<{ label: string; path: string }> = [
{ label: "Drafts folder", path: settings.draftsFolder },
{ label: "Style guide", path: settings.styleGuide },
{ label: "Voice tests", path: settings.voiceTests },
{ label: "Character sheets", path: settings.characterSheetsFolder },
{ label: "Plot outlines", path: settings.plotOutlinesFolder },
{ label: "Wiki folder", path: settings.wikiFolder },
{ label: "Series bible", path: settings.seriesBible },
{ label: "Themes file", path: settings.themesFile },
{ label: "Reviews folder", path: settings.reviewsFolder },
{ label: "Activity log", path: settings.activityLogFolder },
];
const results: string[] = [];
let okCount = 0;
let missingCount = 0;
let emptyCount = 0;
for (const check of checks) {
if (!check.path) {
results.push(`[not set] ${check.label}`);
emptyCount++;
} else {
const abstractFile = vault.getAbstractFileByPath(check.path);
if (abstractFile) {
results.push(`[OK] ${check.label}: ${check.path}`);
okCount++;
} else {
results.push(`[MISSING] ${check.label}: ${check.path}`);
missingCount++;
}
}
}
const summary = `Valid: ${okCount}, Missing: ${missingCount}, Not set: ${emptyCount}`;
new Notice(
`PENNY: Path Validation\n${summary}\n\n${results.join("\n")}`,
12000,
);
}
/**
* Git -- auto-commit, auto-push, commit message format.
*/
@@ -384,4 +914,74 @@ export class PennySettingTab extends PluginSettingTab {
})
);
}
/**
* Help -- command reference and tips.
*/
private renderHelpSection(containerEl: HTMLElement): void {
const details = containerEl.createEl("details");
details.createEl("summary", { text: "Commands Reference" });
details.createEl("p", {
text: "All PENNY commands available in the command palette (Cmd/Ctrl+P).",
cls: "setting-item-description",
});
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 all chapters", desc: "Process all annotated chapters in the current book" },
{ name: "PENNY: Dry run", desc: "Show what would change without processing" },
{ name: "PENNY: Migrate chapters", desc: "Convert flat chapter files to versioned folders" },
{ name: "PENNY: Show status", desc: "Show annotation counts and version info" },
{ 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" },
];
const table = details.createEl("table", { cls: "penny-commands-table" });
const thead = table.createEl("thead");
const headerRow = thead.createEl("tr");
headerRow.createEl("th", { text: "Command" });
headerRow.createEl("th", { text: "Description" });
const tbody = table.createEl("tbody");
for (const cmd of commands) {
const row = tbody.createEl("tr");
row.createEl("td", { text: cmd.name, cls: "penny-command-name" });
row.createEl("td", { text: cmd.desc });
}
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.");
}
}
/**
* Simple fuzzy suggest modal for picking a vault path (folder or file).
*/
class PathSuggestModal extends FuzzySuggestModal<string> {
private paths: string[];
private onChoose: (path: string) => void;
constructor(app: App, paths: string[], onChoose: (path: string) => void) {
super(app);
this.paths = paths;
this.onChoose = onChoose;
this.setPlaceholder("Type to search...");
}
getItems(): string[] {
return this.paths;
}
getItemText(item: string): string {
return item;
}
onChooseItem(item: string): void {
this.onChoose(item);
}
}
+2 -36
View File
@@ -7,6 +7,7 @@
import type { TFile } from "obsidian";
import type PennyPlugin from "./main";
import { globMatch } from "./utils";
/** Status bar display states */
type StatusBarState = "ready" | "processing" | "info";
@@ -101,42 +102,7 @@ export class PennyStatusBar {
* constructing a RegExp from user input to avoid ReDoS.
*/
private isChapterFile(file: TFile, plugin: PennyPlugin): boolean {
return this.globMatch(plugin.settings.chapterFilePattern, file.name);
}
/**
* Match a filename against a simple glob pattern.
* Supports `*` (any sequence of characters) and `?` (any single character).
* Uses iterative comparison instead of RegExp to avoid ReDoS.
*/
private globMatch(pattern: string, text: string): boolean {
let pi = 0;
let ti = 0;
let starPi = -1;
let matchTi = -1;
while (ti < text.length) {
if (pi < pattern.length && (pattern[pi] === text[ti] || pattern[pi] === "?")) {
pi++;
ti++;
} else if (pi < pattern.length && pattern[pi] === "*") {
starPi = pi;
matchTi = ti;
pi++;
} else if (starPi !== -1) {
pi = starPi + 1;
matchTi++;
ti = matchTi;
} else {
return false;
}
}
while (pi < pattern.length && pattern[pi] === "*") {
pi++;
}
return pi === pattern.length;
return globMatch(plugin.settings.chapterFilePattern, file.name);
}
/**
+65 -15
View File
@@ -3,12 +3,28 @@
* Shared type definitions
*/
/** Route configuration for a complexity tier */
export interface ModelRoute {
provider: string;
model: string;
}
/** Plugin settings stored in Obsidian's data.json */
export interface PennySettings {
// API
apiKey: string;
model: string;
// Provider keys & endpoints
anthropicApiKey: string;
ollamaEndpoint: string;
ollamaApiKey: string;
// Model routing
routeLight: ModelRoute;
routeStandard: ModelRoute;
routeHeavy: ModelRoute;
useSameModelForAll: boolean;
// Context
contextBudget: number;
maxTokens: number;
// Project structure (paths relative to vault root)
draftsFolder: string;
@@ -65,16 +81,6 @@ export const DEFAULT_SYSTEM_PROMPT = `You are PENNY, a fiction co-author. You ar
---
## Task: {tag}
### Passage to Revise (Lines {lineStart}-{lineEnd})
{passage}
### Author's Instruction
{instruction}
---
Write the revised passage. Rules:
- Output ONLY the replacement text. No commentary, no meta-text, no code fences.
- Match the project's established prose style exactly.
@@ -82,10 +88,51 @@ Write the revised passage. Rules:
- If voice tests are provided, match them precisely.
- If a character sheet exists for a character in the scene, match their voice.`;
/**
* Migrate legacy settings (pre-provider-abstraction) to the new format.
*
* If saved data contains old `apiKey` or `model` fields, map them to the
* new `anthropicApiKey` and route fields so existing users are not broken.
*/
export function migrateSettings(
saved: Record<string, unknown>,
): Record<string, unknown> {
const migrated = { ...saved };
// Migrate old apiKey -> anthropicApiKey
if ("apiKey" in migrated && !("anthropicApiKey" in migrated)) {
migrated.anthropicApiKey = migrated.apiKey;
}
delete migrated.apiKey;
// Migrate old model -> all three routes (uniform routing)
if ("model" in migrated && typeof migrated.model === "string") {
const oldModel = migrated.model as string;
const route: ModelRoute = { provider: "anthropic", model: oldModel };
if (!("routeLight" in migrated)) migrated.routeLight = { ...route };
if (!("routeStandard" in migrated)) migrated.routeStandard = { ...route };
if (!("routeHeavy" in migrated)) migrated.routeHeavy = { ...route };
if (!("useSameModelForAll" in migrated)) migrated.useSameModelForAll = true;
}
delete migrated.model;
return migrated;
}
export const DEFAULT_SETTINGS: PennySettings = {
apiKey: "",
model: "claude-opus-4-6",
anthropicApiKey: "",
ollamaEndpoint: "http://localhost:11434",
ollamaApiKey: "",
// "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" },
useSameModelForAll: false,
contextBudget: 800000,
maxTokens: 16000,
draftsFolder: "04-drafts",
styleGuide: "",
@@ -213,6 +260,8 @@ export interface AnnotationChange {
originalWordCount: number;
revisedWordCount: number;
contextFilesUsed: string[];
provider: string;
model: string;
}
export interface ReviewFlag {
@@ -240,6 +289,7 @@ export interface ActivityLogEntry {
wordCountAfter: number;
tags: string[];
durationMs: number;
provider: string;
model: string;
voiceCompliance: VoiceComplianceResult;
}
+46
View File
@@ -0,0 +1,46 @@
/**
* PENNY - Shared Utilities
*
* Common utility functions used across multiple modules.
*/
/**
* Simple glob matcher supporting * and ? wildcards.
* Uses iterative comparison to avoid ReDoS.
*
* @param pattern - Glob pattern (supports `*` for any sequence, `?` for single char)
* @param text - The string to match against the pattern
* @returns True if the text matches the pattern
*/
export function globMatch(pattern: string, text: string): boolean {
let pi = 0;
let ti = 0;
let starPi = -1;
let matchTi = -1;
while (ti < text.length) {
if (
pi < pattern.length &&
(pattern[pi] === text[ti] || pattern[pi] === "?")
) {
pi++;
ti++;
} else if (pi < pattern.length && pattern[pi] === "*") {
starPi = pi;
matchTi = ti;
pi++;
} else if (starPi !== -1) {
pi = starPi + 1;
matchTi++;
ti = matchTi;
} else {
return false;
}
}
while (pi < pattern.length && pattern[pi] === "*") {
pi++;
}
return pi === pattern.length;
}
+3 -1
View File
@@ -44,7 +44,9 @@ export function readState(stateContent: string): VersionState {
version: typeof parsed.version === "number" ? parsed.version : 0,
lastProcessed: typeof parsed.lastProcessed === "string" ? parsed.lastProcessed : null,
processedAnnotations: Array.isArray(parsed.processedAnnotations)
? parsed.processedAnnotations.map((pa: Record<string, unknown>) => ({
? parsed.processedAnnotations
.filter((pa: unknown) => typeof pa === "object" && pa !== null)
.map((pa: Record<string, unknown>) => ({
hash: String(pa.hash ?? ""),
tag: String(pa.tag ?? ""),
line: typeof pa.line === "number" ? pa.line : 0,
-1
View File
@@ -95,7 +95,6 @@ export function checkVoiceCompliance(prose: string): VoiceComplianceResult {
const proseLines = prose.split("\n");
for (const line of proseLines) {
for (const pattern of SELF_ANALYSIS_PATTERNS) {
pattern.lastIndex = 0;
const m = pattern.exec(line);
if (m) {
// Return the matched fragment plus surrounding context (the line).
+166
View File
@@ -1,5 +1,127 @@
/* PENNY - Prose Engine for Narrative, Notes, and Yarns */
/* Settings header -- hero banner */
.penny-settings-header {
margin-bottom: 16px;
padding: 24px 28px;
background: linear-gradient(135deg, var(--background-secondary) 0%, var(--background-primary) 100%);
border: 1px solid var(--background-modifier-border);
border-radius: 12px;
}
.penny-title-row {
display: flex;
align-items: baseline;
gap: 12px;
}
.penny-settings-header h1 {
margin: 0 0 2px 0;
font-size: 2.2em;
letter-spacing: -0.02em;
}
.penny-version-badge {
font-size: 0.75em;
color: var(--text-muted);
background: var(--background-modifier-border);
padding: 2px 8px;
border-radius: 10px;
font-weight: 500;
}
.penny-settings-subtitle {
margin-top: 0;
margin-bottom: 12px;
color: var(--text-muted);
font-style: italic;
font-size: 1.05em;
letter-spacing: 0.01em;
}
.penny-settings-header .setting-item-description {
margin-bottom: 14px;
font-size: 0.95em;
line-height: 1.5;
}
.penny-settings-links {
display: flex;
gap: 6px;
align-items: center;
margin-top: 12px;
padding-top: 12px;
border-top: 1px solid var(--background-modifier-border);
font-size: 0.9em;
}
.penny-settings-links a {
color: var(--text-accent);
text-decoration: none;
padding: 4px 10px;
border-radius: 4px;
transition: background 0.15s;
}
.penny-settings-links a:hover {
background: var(--background-modifier-hover);
text-decoration: none;
}
.penny-settings-links span {
color: var(--text-faint);
}
/* Model lists in provider sections */
.penny-model-list {
margin: 4px 0 12px 0;
padding: 8px 12px;
background: var(--background-secondary);
border-radius: 6px;
font-size: 0.9em;
}
.penny-model-row {
padding: 3px 0;
}
.penny-model-name {
font-weight: 600;
}
.penny-model-meta {
color: var(--text-muted);
font-size: 0.9em;
}
/* Settings collapsible sections */
.penny-settings-header + hr + details,
.penny-settings-header + hr ~ details {
margin-top: 16px;
margin-bottom: 16px;
padding: 8px 12px;
border: 1px solid var(--background-modifier-border);
border-radius: 8px;
}
.penny-settings-header + hr ~ details > summary {
cursor: pointer;
font-weight: 600;
font-size: 1.1em;
padding: 8px 0;
user-select: none;
}
.penny-settings-header + hr ~ details > summary:hover {
color: var(--text-accent);
}
.penny-settings-header + hr ~ details[open] > summary {
margin-bottom: 8px;
border-bottom: 1px solid var(--background-modifier-border);
padding-bottom: 8px;
}
.penny-status-bar {
display: flex;
align-items: center;
@@ -38,3 +160,47 @@
.penny-init-modal .setting-item:last-child {
border-bottom: none;
}
/* Commands reference table */
.penny-commands-table {
width: 100%;
border-collapse: collapse;
margin: 8px 0 12px 0;
font-size: 0.9em;
}
.penny-commands-table th {
text-align: left;
padding: 6px 10px;
border-bottom: 2px solid var(--background-modifier-border);
color: var(--text-muted);
font-weight: 600;
font-size: 0.85em;
text-transform: uppercase;
letter-spacing: 0.03em;
}
.penny-commands-table td {
padding: 5px 10px;
border-bottom: 1px solid var(--background-modifier-border);
}
.penny-commands-table tr:last-child td {
border-bottom: none;
}
.penny-command-name {
font-family: var(--font-monospace);
font-size: 0.9em;
white-space: nowrap;
color: var(--text-accent);
}
.penny-help-tip {
margin-top: 12px;
padding: 8px 12px;
background: var(--background-secondary);
border-radius: 6px;
font-size: 0.9em;
color: var(--text-muted);
}
+12 -12
View File
@@ -32,7 +32,7 @@ describe("assembleNewVersion", () => {
scope: "paragraph",
});
const result = assembleNewVersion(original, [
const { content: result } = assembleNewVersion(original, [
{ annotation, revisedText: "Better first paragraph." },
], 2);
@@ -52,7 +52,7 @@ describe("assembleNewVersion", () => {
instruction: "improve",
});
const result = assembleNewVersion(original, [
const { content: result } = assembleNewVersion(original, [
{ annotation, revisedText: "Improved text." },
], 3);
@@ -87,7 +87,7 @@ describe("assembleNewVersion", () => {
lineEnd: 4,
});
const result = assembleNewVersion(original, [
const { content: result } = assembleNewVersion(original, [
{ annotation: anno1, revisedText: "Fixed para one." },
{ annotation: anno2, revisedText: "Expanded para two with more detail." },
], 2);
@@ -108,7 +108,7 @@ describe("assembleNewVersion", () => {
const annotation = makeAnnotation({ lineStart: 0, lineEnd: 1 });
const result = assembleNewVersion(original, [
const { content: result } = assembleNewVersion(original, [
{ annotation, revisedText: "Better text." },
], 2);
@@ -129,7 +129,7 @@ describe("assembleNewVersion", () => {
lineEnd: 4,
});
const result = assembleNewVersion(original, [
const { content: result } = assembleNewVersion(original, [
{ annotation, revisedText: "Fixed text." },
], 2);
@@ -150,7 +150,7 @@ describe("assembleNewVersion", () => {
lineEnd: 4,
});
const result = assembleNewVersion(original, [
const { content: result } = assembleNewVersion(original, [
{ annotation, revisedText: "Revised paragraph." },
], 2);
@@ -161,7 +161,7 @@ describe("assembleNewVersion", () => {
describe("edge cases", () => {
it("handles empty revisions array", () => {
const original = "Some text.\n%% NOTE: keep this %%";
const result = assembleNewVersion(original, [], 2);
const { content: result } = assembleNewVersion(original, [], 2);
expect(result).toContain("Some text.");
expect(result).toContain("%% NOTE: keep this %%");
});
@@ -175,7 +175,7 @@ describe("assembleNewVersion", () => {
const annotation = makeAnnotation({ lineStart: 0, lineEnd: 1 });
const revisedText = "First expanded line.\n\nSecond expanded line.\n\nThird expanded line.";
const result = assembleNewVersion(original, [
const { content: result } = assembleNewVersion(original, [
{ annotation, revisedText },
], 2);
@@ -193,7 +193,7 @@ describe("assembleNewVersion", () => {
actionable: false,
});
const result = assembleNewVersion(original, [
const { content: result } = assembleNewVersion(original, [
{ annotation, revisedText: "Should not replace." },
], 2);
@@ -211,7 +211,7 @@ describe("assembleNewVersion", () => {
instruction: longInstruction,
});
const result = assembleNewVersion(original, [
const { content: result } = assembleNewVersion(original, [
{ annotation, revisedText: "New text." },
], 2);
@@ -228,7 +228,7 @@ describe("assembleNewVersion", () => {
const original = "Text.\n%% REWRITE: fix %%";
const annotation = makeAnnotation({ lineStart: 0, lineEnd: 1 });
const result = assembleNewVersion(original, [
const { content: result } = assembleNewVersion(original, [
{ annotation, revisedText: "Fixed." },
], 7);
@@ -239,7 +239,7 @@ describe("assembleNewVersion", () => {
const original = "Text.\n%% REWRITE: fix %%";
const annotation = makeAnnotation({ lineStart: 0, lineEnd: 1 });
const result = assembleNewVersion(original, [
const { content: result } = assembleNewVersion(original, [
{ annotation, revisedText: "Fixed." },
]);
+15 -70
View File
@@ -1,5 +1,5 @@
import { describe, it, expect } from "vitest";
import { buildPrompt, parseApiResponse } from "../src/drafter";
import { buildPrompt } from "../src/drafter";
import type { AssembledContext, AnnotatedSection } from "../src/types";
import { DEFAULT_SYSTEM_PROMPT } from "../src/types";
@@ -49,7 +49,7 @@ describe("buildPrompt", () => {
expect(system).toContain("No internal monologue.");
});
it("replaces annotation-level placeholders", () => {
it("puts annotation-level details in the user prompt, not the system prompt", () => {
const context = makeContext();
const annotation = makeAnnotation({
tag: "EXPAND",
@@ -59,12 +59,18 @@ describe("buildPrompt", () => {
lineEnd: 17,
});
const { system } = buildPrompt(context, annotation, DEFAULT_SYSTEM_PROMPT);
const { system, user } = buildPrompt(context, annotation, DEFAULT_SYSTEM_PROMPT);
expect(system).toContain("Task: EXPAND");
expect(system).toContain("Lines 15-17");
expect(system).toContain("The room was dark.");
expect(system).toContain("add sensory detail");
// Task details should be in the user prompt
expect(user).toContain("Task: EXPAND");
expect(user).toContain("Lines 15-17");
expect(user).toContain("The room was dark.");
expect(user).toContain("add sensory detail");
// System prompt should NOT duplicate the task/passage/instruction sections
expect(system).not.toContain("Task: EXPAND");
expect(system).not.toContain("Passage to Revise");
expect(system).not.toContain("Author's Instruction");
});
it("builds a user prompt with the task details", () => {
@@ -167,66 +173,5 @@ describe("buildPrompt", () => {
});
});
describe("parseApiResponse", () => {
it("extracts text from a standard Anthropic Messages response", () => {
const response = JSON.stringify({
content: [
{ type: "thinking", thinking: "Let me think about this..." },
{ type: "text", text: "The revised passage goes here." },
],
});
const result = parseApiResponse(response);
expect(result).toBe("The revised passage goes here.");
});
it("handles response with only a text block", () => {
const response = JSON.stringify({
content: [
{ type: "text", text: "Just the text." },
],
});
const result = parseApiResponse(response);
expect(result).toBe("Just the text.");
});
it("handles response with no text block", () => {
const response = JSON.stringify({
content: [
{ type: "thinking", thinking: "Thinking only." },
],
});
const result = parseApiResponse(response);
expect(result).toBe("");
});
it("handles empty content array", () => {
const response = JSON.stringify({ content: [] });
const result = parseApiResponse(response);
expect(result).toBe("");
});
it("handles invalid JSON gracefully", () => {
const result = parseApiResponse("not json at all");
expect(result).toBe("not json at all");
});
it("trims whitespace from extracted text", () => {
const response = JSON.stringify({
content: [
{ type: "text", text: " trimmed text \n" },
],
});
const result = parseApiResponse(response);
expect(result).toBe("trimmed text");
});
it("handles plain string JSON", () => {
const response = JSON.stringify("plain string response");
const result = parseApiResponse(response);
expect(result).toBe("plain string response");
});
});
// NOTE: parseApiResponse tests have been moved to test/providers/anthropic.test.ts
// and test/providers/ollama.test.ts since response parsing is now provider-specific.
+58
View File
@@ -103,6 +103,21 @@ describe("parseFrontmatter", () => {
const { frontmatter } = parseFrontmatter(content);
expect(frontmatter.characters_in_scene).toEqual([]);
});
it("parses frontmatter with CRLF line endings", () => {
const content = "---\r\ntype: chapter\r\nbook: 1\r\ntitle: Coffee\r\n---\r\nBody text.";
const { frontmatter, body } = parseFrontmatter(content);
expect(frontmatter.type).toBe("chapter");
expect(frontmatter.book).toBe(1);
expect(frontmatter.title).toBe("Coffee");
expect(body).toBe("Body text.");
});
it("parses array fields with CRLF line endings", () => {
const content = "---\r\ncharacters_in_scene:\r\n - protagonist\r\n - tim\r\n---\r\nBody.";
const { frontmatter } = parseFrontmatter(content);
expect(frontmatter.characters_in_scene).toEqual(["protagonist", "tim"]);
});
});
describe("serializeFrontmatter", () => {
@@ -152,6 +167,49 @@ describe("serializeFrontmatter", () => {
expect(result).not.toContain("focus:");
});
it("quotes string values containing YAML-unsafe characters", () => {
const frontmatter = {
title: "Chapter: The Beginning",
note: "see #section for details",
tag: "[important]",
ref: "{key: value}",
special: "@mention this",
anchor: "&anchor",
star: "*bold*",
bang: "!warning",
};
const result = serializeFrontmatter(frontmatter, "Body.");
// Values with : # [ ] { } and leading @!*& should be double-quoted
expect(result).toContain('title: "Chapter: The Beginning"');
expect(result).toContain('note: "see #section for details"');
expect(result).toContain('tag: "[important]"');
expect(result).toContain('ref: "{key: value}"');
expect(result).toContain('special: "@mention this"');
expect(result).toContain('anchor: "&anchor"');
expect(result).toContain('star: "*bold*"');
expect(result).toContain('bang: "!warning"');
});
it("quotes string values with leading/trailing whitespace", () => {
const frontmatter = { title: " padded " };
const result = serializeFrontmatter(frontmatter, "Body.");
expect(result).toContain('title: " padded "');
});
it("escapes internal double quotes when quoting", () => {
const frontmatter = { title: 'She said "hello"' };
const result = serializeFrontmatter(frontmatter, "Body.");
expect(result).toContain('title: "She said \\"hello\\""');
});
it("does not quote safe string values", () => {
const frontmatter = { title: "Rabbit" };
const result = serializeFrontmatter(frontmatter, "Body.");
expect(result).toContain("title: Rabbit");
expect(result).not.toContain('"Rabbit"');
});
it("roundtrips parse -> serialize -> parse", () => {
const original = [
"---",
+460
View File
@@ -0,0 +1,460 @@
/**
* PENNY - Integration Test
*
* Tests the core processing pipeline (runPipeline) end-to-end
* with a mock LLM provider. Verifies:
* - Annotations are parsed and processed
* - Output has REVISED markers and annotations removed
* - Version number increments correctly
* - State tracks processed annotation hashes
* - Review note contains correct data
* - Idempotency: second run on same content produces no changes
*/
import { describe, it, expect } from "vitest";
import { runPipeline } from "../src/pipeline";
import type { PipelineInput, PipelineResult } 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";
// ---------------------------------------------------------------------------
// Test fixtures
// ---------------------------------------------------------------------------
const TEST_CHAPTER = `---
type: chapter
book: 1
chapter: 5
title: The Discovery
pov: protagonist
status: draft
wordcount: 100
focus: protagonist + tim
thread: mystery
act: 2
---
# Book 1, Chapter 5
## Scene Summary
> She investigates the anomaly with Tim.
---
<!-- Prose begins below -->
The lab was quiet at 3am. She stared at the screen.
%% REWRITE: More sensory detail - sounds, smells, the hum of equipment %%
"I don't understand what I'm seeing," she said to Rabbit.
%% EXPAND: Add more dialogue here. Show her working through the problem out loud. %%
The data made no sense.
%% NOTE: Come back to this scene after writing chapter 6. %%
%% RESEARCH: Is the technical description accurate? %%
"I think I've found it," she said.
`;
const TEST_STYLE_GUIDE = `# Style Guide
First person. Past tense. Short sentences.`;
const TEST_VOICE_TESTS = `# Voice Tests
## Protagonist Solo (with Rabbit)
"Okay, Rabbit. Here's the thing about garbage collection."
## Protagonist with Tim
"You need to eat something that isn't coffee."
`;
const TEST_SETTINGS: PennySettings = {
...DEFAULT_SETTINGS,
systemPromptTemplate: DEFAULT_SYSTEM_PROMPT,
proseMarker: "<!-- Prose begins below -->",
};
function makeContextFiles(chapter: string): ContextFiles {
return {
chapter,
voiceTests: TEST_VOICE_TESTS,
styleGuide: TEST_STYLE_GUIDE,
outline: "",
characters: [],
wiki: [],
seriesBible: "",
themes: "",
};
}
/**
* Create a mock provider that returns predetermined text for each call.
* Calls are answered in order from the responses array.
*/
function createMockProvider(responses: string[]) {
let callIndex = 0;
const calls: CompletionRequest[] = [];
return {
provider: {
name: "mock",
requiresApiKey: false,
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 },
};
},
estimateTokens(text: string): number {
return text.split(/\s+/).length;
},
async getModels() {
return [];
},
async testConnection() {
return null;
},
},
getCalls: () => calls,
};
}
function makePipelineInput(
content: string,
versionContent: string,
stateContent: string,
mockProvider: ReturnType<typeof createMockProvider>,
): PipelineInput {
return {
content,
versionContent,
stateContent,
contextFiles: makeContextFiles(content),
settings: TEST_SETTINGS,
chapterId: "ch-05",
bookId: "book-1",
getProvider: (name: string) => {
// Return the mock provider for any provider name
return mockProvider.provider;
},
};
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
describe("runPipeline (integration)", () => {
it("processes multiple annotation types and produces a versioned output", async () => {
const mock = createMockProvider([
// Response for REWRITE annotation
"The lab hummed at 3am. The fluorescent lights buzzed overhead, casting everything in that flat institutional glow. The screen threw blue light across my face. Somewhere a fan clicked. The building smelled like old carpet and ozone.",
// Response for EXPAND annotation
'"I don\'t understand what I\'m seeing," I said to Rabbit.\n\n"The numbers keep shifting. Every time I think I\'ve got the pattern, it moves. Like it knows I\'m watching."\n\nI pulled my coffee closer. Chairman Meow stretched across the keyboard.\n\n"That\'s not helping," I told him. He didn\'t care.',
]);
const input = makePipelineInput(TEST_CHAPTER, "1", "", mock);
const result = await runPipeline(input);
expect(result).not.toBeNull();
const r = result as PipelineResult;
// Version should increment
expect(r.newVersion).toBe(2);
// Should have processed 2 actionable annotations (REWRITE + EXPAND)
expect(r.annotationsProcessed).toBe(2);
// Passthrough annotations (NOTE + RESEARCH) should be counted as skipped
expect(r.annotationsSkipped).toBe(2);
// Tags should be tracked
expect(r.tags).toContain("REWRITE");
expect(r.tags).toContain("EXPAND");
// No errors expected
expect(r.hadErrors).toBe(false);
// The new content should contain REVISED markers
expect(r.newContent).toContain("REVISED(v2)");
expect(r.newContent).toContain("[REWRITE]");
expect(r.newContent).toContain("[EXPAND]");
// The new content should NOT contain the original actionable annotations
expect(r.newContent).not.toContain("%% REWRITE:");
expect(r.newContent).not.toContain("%% EXPAND:");
// The passthrough annotations should be preserved
expect(r.newContent).toContain("%% NOTE:");
expect(r.newContent).toContain("%% RESEARCH:");
// The revised text should be in the output
expect(r.newContent).toContain("fluorescent lights buzzed");
expect(r.newContent).toContain("Chairman Meow stretched");
// The mock provider should have been called twice
expect(mock.getCalls()).toHaveLength(2);
// State JSON should track the processed annotations
const state = JSON.parse(r.stateJson);
expect(state.version).toBe(2);
expect(state.processedAnnotations).toHaveLength(2);
expect(state.processedAnnotations[0].tag).toBeDefined();
expect(state.processedAnnotations[0].hash).toBeDefined();
// Review content should be valid markdown
expect(r.reviewContent).toContain("# ch-05 Review -- v2");
expect(r.reviewContent).toContain("**Annotations processed:** 2");
expect(r.reviewContent).toContain("**Annotations skipped (NOTE/RESEARCH):** 2");
expect(r.reviewContent).toContain("## Changes Made");
expect(r.reviewContent).toContain("[REWRITE]");
expect(r.reviewContent).toContain("[EXPAND]");
expect(r.reviewContent).toContain("## Voice Compliance");
expect(r.reviewContent).toContain("## Word Count");
// Log line should be valid JSONL
const logEntry = JSON.parse(r.logLine.trim());
expect(logEntry.book).toBe("book-1");
expect(logEntry.chapter).toBe("ch-05");
expect(logEntry.versionFrom).toBe(1);
expect(logEntry.versionTo).toBe(2);
expect(logEntry.annotationsProcessed).toBe(2);
});
it("returns null when there are no actionable annotations", async () => {
const contentNoAnnotations = `---
type: chapter
book: 1
chapter: 3
---
# Chapter 3
Some prose without any annotations.
%% NOTE: Just a note %%
%% RESEARCH: Something to look up %%
`;
const mock = createMockProvider([]);
const input = makePipelineInput(contentNoAnnotations, "1", "", mock);
const result = await runPipeline(input);
expect(result).toBeNull();
// Provider should never be called
expect(mock.getCalls()).toHaveLength(0);
});
it("is idempotent: second run with same state skips already-processed annotations", async () => {
const mock1 = createMockProvider([
"Revised lab scene.",
"Revised dialogue scene.",
]);
// First run
const input1 = makePipelineInput(TEST_CHAPTER, "1", "", mock1);
const result1 = await runPipeline(input1);
expect(result1).not.toBeNull();
expect(result1!.annotationsProcessed).toBe(2);
// Second run with the state from the first run
const mock2 = createMockProvider([]);
const input2 = makePipelineInput(TEST_CHAPTER, "2", result1!.stateJson, mock2);
const result2 = await runPipeline(input2);
// Should return null -- nothing new to process
expect(result2).toBeNull();
// Provider should not have been called
expect(mock2.getCalls()).toHaveLength(0);
});
it("handles provider errors gracefully with AGENT-ERROR markers", async () => {
// First call succeeds, second call throws
let callCount = 0;
const failingProvider = {
name: "failing",
requiresApiKey: false,
async complete(request: CompletionRequest): Promise<CompletionResponse> {
callCount++;
if (callCount === 2) {
throw new Error("Rate limit exceeded");
}
return {
text: "Revised text for first annotation.",
model: request.model,
provider: "failing",
};
},
estimateTokens(text: string) {
return text.split(/\s+/).length;
},
async getModels() {
return [];
},
async testConnection() {
return null;
},
};
const input: PipelineInput = {
content: TEST_CHAPTER,
versionContent: "1",
stateContent: "",
contextFiles: makeContextFiles(TEST_CHAPTER),
settings: TEST_SETTINGS,
chapterId: "ch-05",
bookId: "book-1",
getProvider: () => failingProvider,
};
const result = await runPipeline(input);
expect(result).not.toBeNull();
const r = result as PipelineResult;
// Should still produce output (not throw)
expect(r.newVersion).toBe(2);
expect(r.hadErrors).toBe(true);
// The output should contain the AGENT-ERROR marker for the failed annotation
expect(r.newContent).toContain("AGENT-ERROR");
expect(r.newContent).toContain("Rate limit exceeded");
// The review should flag the error
expect(r.reviewContent).toContain("error");
// Only 1 annotation should be counted as successfully processed
expect(r.annotationsProcessed).toBe(1);
});
it("handles missing provider gracefully", async () => {
const input: PipelineInput = {
content: TEST_CHAPTER,
versionContent: "0",
stateContent: "",
contextFiles: makeContextFiles(TEST_CHAPTER),
settings: TEST_SETTINGS,
chapterId: "ch-05",
bookId: "book-1",
getProvider: () => undefined, // No provider available
};
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);
});
it("updates frontmatter with agent fields", async () => {
const mock = createMockProvider([
"Revised first passage.",
"Revised second passage.",
]);
const input = makePipelineInput(TEST_CHAPTER, "1", "", mock);
const result = await runPipeline(input);
expect(result).not.toBeNull();
const r = result as PipelineResult;
// Check frontmatter fields in the output
expect(r.newContent).toContain("agent_version: 2");
expect(r.newContent).toContain("agent_last_revised:");
expect(r.newContent).toContain("wordcount:");
});
it("builds prompts with context from provided files", async () => {
const mock = createMockProvider(["Revised passage."]);
// Use a chapter with a single annotation for simpler verification
const simpleChapter = `---
type: chapter
book: 1
chapter: 1
focus: tim
---
# Chapter 1
<!-- Prose begins below -->
She walked in.
%% REWRITE: Add sensory detail %%
`;
const input: PipelineInput = {
content: simpleChapter,
versionContent: "0",
stateContent: "",
contextFiles: {
chapter: simpleChapter,
voiceTests: TEST_VOICE_TESTS,
styleGuide: TEST_STYLE_GUIDE,
outline: "Plot outline here.",
characters: ["Tim character sheet."],
wiki: [],
},
settings: TEST_SETTINGS,
chapterId: "ch-01",
bookId: "book-1",
getProvider: () => mock.provider,
};
const result = await runPipeline(input);
expect(result).not.toBeNull();
// Verify the prompt sent to the provider contained context
const calls = mock.getCalls();
expect(calls).toHaveLength(1);
const call = calls[0];
// System prompt should contain context from files
expect(call.systemPrompt).toContain("Style Guide");
expect(call.systemPrompt).toContain("First person. Past tense.");
// User prompt should contain the task details
expect(call.userPrompt).toContain("REWRITE");
expect(call.userPrompt).toContain("Add sensory detail");
expect(call.userPrompt).toContain("She walked in.");
// System prompt should NOT have the task/passage/instruction
// (those were removed from the template -- LOW-2 fix)
expect(call.systemPrompt).not.toContain("Passage to Revise");
expect(call.systemPrompt).not.toContain("Author's Instruction");
});
it("starts from version 0 when no .version file exists", async () => {
const mock = createMockProvider(["Revised."]);
const simpleChapter = `---
type: chapter
book: 1
chapter: 1
---
She walked in.
%% REWRITE: Fix this %%
`;
const input = makePipelineInput(simpleChapter, "", "", mock);
const result = await runPipeline(input);
expect(result).not.toBeNull();
// readVersion("") returns 0, so nextVersion should be 1
expect(result!.newVersion).toBe(1);
});
});
+216
View File
@@ -0,0 +1,216 @@
import { describe, it, expect } from "vitest";
import { parseProjectConfig } from "../src/project-config";
describe("parseProjectConfig", () => {
describe("key-value parsing", () => {
it("parses simple key-value pairs from the Project section", () => {
const content = [
"# PENNY.md",
"",
"## Project",
"",
"drafts: my-drafts",
"style-guide: reference/style.md",
"wiki: 05-wiki",
"",
"## Other Section",
"",
"This is not parsed.",
].join("\n");
const result = parseProjectConfig(content);
expect(result.draftsFolder).toBe("my-drafts");
expect(result.styleGuide).toBe("reference/style.md");
expect(result.wikiFolder).toBe("05-wiki");
});
it("parses numeric values for contextBudget and maxTokens", () => {
const content = [
"## Project",
"",
"context-budget: 500000",
"max-tokens: 8000",
].join("\n");
const result = parseProjectConfig(content);
expect(result.contextBudget).toBe(500000);
expect(result.maxTokens).toBe(8000);
});
it("ignores invalid numeric values", () => {
const content = [
"## Project",
"",
"context-budget: not-a-number",
"max-tokens: -5",
].join("\n");
const result = parseProjectConfig(content);
expect(result.contextBudget).toBeUndefined();
expect(result.maxTokens).toBeUndefined();
});
it("ignores unknown keys", () => {
const content = [
"## Project",
"",
"drafts: my-drafts",
"unknown-key: some-value",
"another: thing",
].join("\n");
const result = parseProjectConfig(content);
expect(result.draftsFolder).toBe("my-drafts");
expect(Object.keys(result)).not.toContain("unknown-key");
});
it("stops parsing at the next heading", () => {
const content = [
"## Project",
"",
"drafts: correct-folder",
"",
"## Something Else",
"",
"drafts: wrong-folder",
].join("\n");
const result = parseProjectConfig(content);
expect(result.draftsFolder).toBe("correct-folder");
});
});
describe("voice rules extraction", () => {
it("extracts voice rules from the Voice Rules section", () => {
const content = [
"## Project",
"",
"drafts: 04-drafts",
"",
"## Voice Rules",
"",
"- No internal monologue.",
"- She is never named.",
"- Humor is structural.",
].join("\n");
const result = parseProjectConfig(content);
expect(result.customVoiceRules).toBeDefined();
expect(result.customVoiceRules).toContain("No internal monologue.");
expect(result.customVoiceRules).toContain("She is never named.");
expect(result.customVoiceRules).toContain("Humor is structural.");
});
it("returns no customVoiceRules when section is empty", () => {
const content = [
"## Project",
"",
"drafts: 04-drafts",
"",
"## Voice Rules",
"",
"## Other Section",
].join("\n");
const result = parseProjectConfig(content);
expect(result.customVoiceRules).toBeUndefined();
});
});
describe("HTML comment skipping", () => {
it("skips single-line HTML comments in voice rules", () => {
const content = [
"## Voice Rules",
"",
"- Rule one.",
"<!-- This is a comment -->",
"- Rule two.",
].join("\n");
const result = parseProjectConfig(content);
expect(result.customVoiceRules).toContain("Rule one.");
expect(result.customVoiceRules).toContain("Rule two.");
expect(result.customVoiceRules).not.toContain("This is a comment");
});
it("skips multi-line HTML comments in voice rules", () => {
const content = [
"## Voice Rules",
"",
"- Rule one.",
"<!-- This is a",
"multi-line comment",
"that spans several lines -->",
"- Rule two.",
].join("\n");
const result = parseProjectConfig(content);
expect(result.customVoiceRules).toContain("Rule one.");
expect(result.customVoiceRules).toContain("Rule two.");
expect(result.customVoiceRules).not.toContain("multi-line comment");
});
it("handles adjacent HTML comments", () => {
const content = [
"## Voice Rules",
"",
"<!-- comment one -->",
"<!-- comment two -->",
"- Actual rule.",
].join("\n");
const result = parseProjectConfig(content);
expect(result.customVoiceRules).toContain("Actual rule.");
expect(result.customVoiceRules).not.toContain("comment one");
expect(result.customVoiceRules).not.toContain("comment two");
});
});
describe("missing sections", () => {
it("returns empty overrides when no Project section exists", () => {
const content = [
"# PENNY.md",
"",
"This file has no Project section.",
"",
"## Other",
"",
"stuff: things",
].join("\n");
const result = parseProjectConfig(content);
// Should have no keys (or only customVoiceRules if empty)
expect(result.draftsFolder).toBeUndefined();
expect(result.styleGuide).toBeUndefined();
});
it("returns empty overrides for completely empty content", () => {
const result = parseProjectConfig("");
expect(Object.keys(result).length).toBe(0);
});
it("handles content with only a Project heading and no values", () => {
const content = [
"## Project",
"",
"Some prose but no key: value pairs with recognized keys.",
].join("\n");
const result = parseProjectConfig(content);
expect(result.draftsFolder).toBeUndefined();
});
});
});
+407
View File
@@ -0,0 +1,407 @@
import { describe, it, expect, vi } from "vitest";
import { AnthropicProvider } from "../../src/providers/anthropic";
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 Anthropic Messages API response body. */
function anthropicResponse(text: string, opts?: {
includeThinking?: boolean;
inputTokens?: number;
outputTokens?: number;
}): string {
const content: Array<Record<string, unknown>> = [];
if (opts?.includeThinking) {
content.push({ type: "thinking", thinking: "Let me consider this..." });
}
content.push({ type: "text", text });
return JSON.stringify({
content,
usage: {
input_tokens: opts?.inputTokens ?? 100,
output_tokens: opts?.outputTokens ?? 50,
},
});
}
function makeRequest(overrides: Partial<CompletionRequest> = {}): CompletionRequest {
return {
systemPrompt: "You are a writing assistant.",
userPrompt: "Rewrite this passage with more tension.",
model: "claude-sonnet-4-6",
maxTokens: 4096,
apiKey: "sk-test-key-123",
...overrides,
};
}
describe("AnthropicProvider", () => {
describe("metadata", () => {
it("has name 'anthropic'", () => {
const { fn } = mockHttp();
const provider = new AnthropicProvider(fn);
expect(provider.name).toBe("anthropic");
});
it("requires an API key", () => {
const { fn } = mockHttp();
const provider = new AnthropicProvider(fn);
expect(provider.requiresApiKey).toBe(true);
});
});
describe("getModels", () => {
it("returns static list of Claude models", async () => {
const { fn } = mockHttp();
const provider = new AnthropicProvider(fn);
const models = await provider.getModels();
expect(models.length).toBe(3);
const ids = models.map((m) => m.id);
expect(ids).toContain("claude-opus-4-6");
expect(ids).toContain("claude-sonnet-4-6");
expect(ids).toContain("claude-haiku-4-5");
});
it("includes context window and pricing info", async () => {
const { fn } = mockHttp();
const provider = new AnthropicProvider(fn);
const models = await provider.getModels();
const opus = models.find((m) => m.id === "claude-opus-4-6");
expect(opus).toBeDefined();
expect(opus!.contextWindow).toBe(200000);
expect(opus!.costPer1kInput).toBeGreaterThan(0);
expect(opus!.costPer1kOutput).toBeGreaterThan(0);
});
});
describe("complete", () => {
it("sends correct request body format", async () => {
const responseText = anthropicResponse("Revised passage.");
const { fn, calls } = mockHttp({ text: responseText });
const provider = new AnthropicProvider(fn);
await provider.complete(makeRequest({ useThinking: true }));
expect(calls).toHaveLength(1);
const body = JSON.parse(calls[0].body!);
expect(body.model).toBe("claude-sonnet-4-6");
expect(body.max_tokens).toBe(4096);
expect(body.thinking).toBeDefined();
expect(body.thinking.type).toBe("adaptive");
expect(body.system).toBe("You are a writing assistant.");
expect(body.messages).toHaveLength(1);
expect(body.messages[0].role).toBe("user");
expect(body.messages[0].content).toBe("Rewrite this passage with more tension.");
});
it("includes thinking for opus-4-6 when useThinking is true", 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 }));
const body = JSON.parse(calls[0].body!);
expect(body.thinking).toBeDefined();
expect(body.thinking.type).toBe("adaptive");
});
it("includes thinking for sonnet-4-6 when useThinking is true", async () => {
const responseText = anthropicResponse("OK");
const { fn, calls } = mockHttp({ text: responseText });
const provider = new AnthropicProvider(fn);
await provider.complete(makeRequest({ model: "claude-sonnet-4-6", useThinking: true }));
const body = JSON.parse(calls[0].body!);
expect(body.thinking).toBeDefined();
expect(body.thinking.type).toBe("adaptive");
});
it("does NOT include thinking for haiku-4-5 even when useThinking is true", async () => {
const responseText = anthropicResponse("OK");
const { fn, calls } = mockHttp({ text: responseText });
const provider = new AnthropicProvider(fn);
await provider.complete(makeRequest({ model: "claude-haiku-4-5", useThinking: true }));
const body = JSON.parse(calls[0].body!);
expect(body.thinking).toBeUndefined();
});
it("does NOT include thinking when useThinking is false even for capable models", 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 }));
const body = JSON.parse(calls[0].body!);
expect(body.thinking).toBeUndefined();
});
it("does NOT include thinking when useThinking is omitted", async () => {
const responseText = anthropicResponse("OK");
const { fn, calls } = mockHttp({ text: responseText });
const provider = new AnthropicProvider(fn);
await provider.complete(makeRequest({ model: "claude-sonnet-4-6" }));
const body = JSON.parse(calls[0].body!);
expect(body.thinking).toBeUndefined();
});
it("sends correct headers", async () => {
const responseText = anthropicResponse("OK");
const { fn, calls } = mockHttp({ text: responseText });
const provider = new AnthropicProvider(fn);
await provider.complete(makeRequest());
expect(calls).toHaveLength(1);
const headers = calls[0].headers!;
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");
});
it("calls the correct API URL", async () => {
const responseText = anthropicResponse("OK");
const { fn, calls } = mockHttp({ text: responseText });
const provider = new AnthropicProvider(fn);
await provider.complete(makeRequest());
expect(calls[0].url).toBe("https://api.anthropic.com/v1/messages");
});
it("extracts text from a standard response", async () => {
const responseText = anthropicResponse("The door slammed shut.");
const { fn } = mockHttp({ text: responseText });
const provider = new AnthropicProvider(fn);
const result = await provider.complete(makeRequest());
expect(result.text).toBe("The door slammed shut.");
expect(result.provider).toBe("anthropic");
expect(result.model).toBe("claude-sonnet-4-6");
});
it("skips thinking blocks and extracts only text", async () => {
const responseText = anthropicResponse("Final answer.", { includeThinking: true });
const { fn } = mockHttp({ text: responseText });
const provider = new AnthropicProvider(fn);
const result = await provider.complete(makeRequest());
expect(result.text).toBe("Final answer.");
expect(result.text).not.toContain("Let me consider");
});
it("parses usage information", async () => {
const responseText = anthropicResponse("Output.", {
inputTokens: 500,
outputTokens: 200,
});
const { fn } = mockHttp({ text: responseText });
const provider = new AnthropicProvider(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 text block", async () => {
const responseText = JSON.stringify({
content: [{ type: "thinking", thinking: "..." }],
usage: { input_tokens: 10, output_tokens: 0 },
});
const { fn } = mockHttp({ text: responseText });
const provider = new AnthropicProvider(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 AnthropicProvider(fn);
await expect(
provider.complete(makeRequest({ apiKey: undefined })),
).rejects.toThrow("Anthropic API key is required");
});
it("uses the model from the request", 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" }));
const body = JSON.parse(calls[0].body!);
expect(body.model).toBe("claude-opus-4-6");
});
it("throws on 400 response with error message", async () => {
const errorResponse = JSON.stringify({
error: { type: "invalid_request_error", message: "max_tokens must be positive" },
});
const { fn } = mockHttp({ status: 400, text: errorResponse });
const provider = new AnthropicProvider(fn);
await expect(provider.complete(makeRequest())).rejects.toThrow(
"Anthropic API error (400): max_tokens must be positive"
);
});
it("throws 'Invalid API key' on 401 response", async () => {
const errorResponse = JSON.stringify({
error: { type: "authentication_error", message: "invalid x-api-key" },
});
const { fn } = mockHttp({ status: 401, text: errorResponse });
const provider = new AnthropicProvider(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: { type: "rate_limit_error", message: "Too many requests" },
});
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");
});
it("throws on 500 response with server error text", async () => {
const errorResponse = JSON.stringify({
error: { type: "api_error", message: "Internal server error" },
});
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");
});
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");
});
});
describe("estimateTokens", () => {
it("estimates tokens as words * 1.33", () => {
const { fn } = mockHttp();
const provider = new AnthropicProvider(fn);
// 10 words
const text = "one two three four five six seven eight nine ten";
const estimate = provider.estimateTokens(text);
expect(estimate).toBe(Math.ceil(10 * 1.33)); // 14
});
it("handles empty text", () => {
const { fn } = mockHttp();
const provider = new AnthropicProvider(fn);
expect(provider.estimateTokens("")).toBe(0);
});
it("handles single word", () => {
const { fn } = mockHttp();
const provider = new AnthropicProvider(fn);
expect(provider.estimateTokens("hello")).toBe(Math.ceil(1 * 1.33)); // 2
});
it("ignores extra whitespace in word count", () => {
const { fn } = mockHttp();
const provider = new AnthropicProvider(fn);
const text = " spaced out text ";
expect(provider.estimateTokens(text)).toBe(Math.ceil(3 * 1.33)); // 4
});
});
describe("testConnection", () => {
it("returns null on successful connection", async () => {
const responseText = anthropicResponse("pong");
const { fn } = mockHttp({ status: 200, text: responseText });
const provider = new AnthropicProvider(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 AnthropicProvider(fn);
const result = await provider.testConnection({});
expect(result).toBe("Anthropic API key is required");
});
it("returns error message on non-200 status", async () => {
const { fn } = mockHttp({ status: 401, text: '{"error":"invalid_api_key"}' });
const provider = new AnthropicProvider(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 AnthropicProvider(fn);
const result = await provider.testConnection({ apiKey: "sk-key" });
expect(result).toContain("Network error");
});
it("sends a minimal request to verify the key", async () => {
const responseText = anthropicResponse("ok");
const { fn, calls } = mockHttp({ status: 200, text: responseText });
const provider = new AnthropicProvider(fn);
await provider.testConnection({ apiKey: "sk-test" });
expect(calls).toHaveLength(1);
const body = JSON.parse(calls[0].body!);
expect(body.max_tokens).toBe(16);
expect(body.messages).toHaveLength(1);
expect(calls[0].headers!["x-api-key"]).toBe("sk-test");
});
});
});
+388
View File
@@ -0,0 +1,388 @@
import { describe, it, expect } from "vitest";
import { OllamaProvider } from "../../src/providers/ollama";
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-compatible chat completions response. */
function ollamaResponse(text: string, opts?: {
promptTokens?: number;
completionTokens?: number;
}): string {
return JSON.stringify({
choices: [
{
index: 0,
message: { role: "assistant", content: text },
finish_reason: "stop",
},
],
usage: {
prompt_tokens: opts?.promptTokens ?? 50,
completion_tokens: opts?.completionTokens ?? 30,
},
});
}
/** Build a /api/tags response listing local models. */
function tagsResponse(modelNames: string[]): string {
return JSON.stringify({
models: modelNames.map((name) => ({
name,
model: name,
modified_at: "2024-01-01T00:00:00Z",
size: 1000000000,
})),
});
}
function makeRequest(overrides: Partial<CompletionRequest> = {}): CompletionRequest {
return {
systemPrompt: "You are a writing assistant.",
userPrompt: "Rewrite this passage.",
model: "llama3.2",
maxTokens: 2048,
endpoint: "http://localhost:11434",
...overrides,
};
}
describe("OllamaProvider", () => {
describe("metadata", () => {
it("has name 'ollama'", () => {
const { fn } = mockHttp();
const provider = new OllamaProvider(fn);
expect(provider.name).toBe("ollama");
});
it("does not require an API key", () => {
const { fn } = mockHttp();
const provider = new OllamaProvider(fn);
expect(provider.requiresApiKey).toBe(false);
});
});
describe("getModels", () => {
it("fetches models from /api/tags", async () => {
const text = tagsResponse(["llama3.2", "mistral", "deepseek-coder"]);
const { fn, calls } = mockHttp({ text });
const provider = new OllamaProvider(fn);
const models = await provider.getModels({ endpoint: "http://localhost:11434" });
expect(calls).toHaveLength(1);
expect(calls[0].url).toBe("http://localhost:11434/api/tags");
expect(calls[0].method).toBe("GET");
expect(models).toHaveLength(3);
expect(models.map((m) => m.id)).toEqual(["llama3.2", "mistral", "deepseek-coder"]);
});
it("returns model names in both id and name fields", async () => {
const text = tagsResponse(["llama3.2"]);
const { fn } = mockHttp({ text });
const provider = new OllamaProvider(fn);
const models = await provider.getModels({ endpoint: "http://localhost:11434" });
expect(models[0].id).toBe("llama3.2");
expect(models[0].name).toBe("llama3.2");
});
it("sets cost to 0 for local models", async () => {
const text = tagsResponse(["llama3.2"]);
const { fn } = mockHttp({ text });
const provider = new OllamaProvider(fn);
const models = await provider.getModels({ endpoint: "http://localhost:11434" });
expect(models[0].costPer1kInput).toBe(0);
expect(models[0].costPer1kOutput).toBe(0);
});
it("throws when Ollama is not running (connection refused)", async () => {
const fn: HttpFn = async () => {
throw new Error("Connection refused");
};
const provider = new OllamaProvider(fn);
await expect(
provider.getModels({ endpoint: "http://localhost:11434" }),
).rejects.toThrow("Cannot connect to Ollama at http://localhost:11434. Is Ollama running?");
});
it("returns empty array for unexpected response format", async () => {
const { fn } = mockHttp({ text: '{"something": "else"}' });
const provider = new OllamaProvider(fn);
const models = await provider.getModels({ endpoint: "http://localhost:11434" });
expect(models).toEqual([]);
});
it("uses default endpoint when settings omit it", async () => {
const text = tagsResponse(["llama3.2"]);
const { fn, calls } = mockHttp({ text });
const provider = new OllamaProvider(fn);
await provider.getModels();
expect(calls[0].url).toBe("http://localhost:11434/api/tags");
});
it("uses custom endpoint from settings", async () => {
const text = tagsResponse(["llama3.2"]);
const { fn, calls } = mockHttp({ text });
const provider = new OllamaProvider(fn);
await provider.getModels({ endpoint: "http://remote-server:8080" });
expect(calls[0].url).toBe("http://remote-server:8080/api/tags");
});
});
describe("complete", () => {
it("sends correct OpenAI-format request body", async () => {
const responseText = ollamaResponse("Revised text.");
const { fn, calls } = mockHttp({ text: responseText });
const provider = new OllamaProvider(fn);
await provider.complete(makeRequest());
expect(calls).toHaveLength(1);
const body = JSON.parse(calls[0].body!);
expect(body.model).toBe("llama3.2");
expect(body.max_tokens).toBe(2048);
expect(body.stream).toBe(false);
expect(body.messages).toHaveLength(2);
expect(body.messages[0]).toEqual({ role: "system", content: "You are a writing assistant." });
expect(body.messages[1]).toEqual({ role: "user", content: "Rewrite this passage." });
});
it("calls the correct endpoint URL", async () => {
const responseText = ollamaResponse("ok");
const { fn, calls } = mockHttp({ text: responseText });
const provider = new OllamaProvider(fn);
await provider.complete(makeRequest({ endpoint: "http://myserver:1234" }));
expect(calls[0].url).toBe("http://myserver:1234/v1/chat/completions");
});
it("uses default endpoint when none provided", async () => {
const responseText = ollamaResponse("ok");
const { fn, calls } = mockHttp({ text: responseText });
const provider = new OllamaProvider(fn);
await provider.complete(makeRequest({ endpoint: undefined }));
expect(calls[0].url).toBe("http://localhost:11434/v1/chat/completions");
});
it("extracts text from choices[0].message.content", async () => {
const responseText = ollamaResponse("She kicked the door open.");
const { fn } = mockHttp({ text: responseText });
const provider = new OllamaProvider(fn);
const result = await provider.complete(makeRequest());
expect(result.text).toBe("She kicked the door open.");
expect(result.provider).toBe("ollama");
expect(result.model).toBe("llama3.2");
});
it("parses usage information", async () => {
const responseText = ollamaResponse("Output.", {
promptTokens: 300,
completionTokens: 150,
});
const { fn } = mockHttp({ text: responseText });
const provider = new OllamaProvider(fn);
const result = await provider.complete(makeRequest());
expect(result.usage).toBeDefined();
expect(result.usage!.inputTokens).toBe(300);
expect(result.usage!.outputTokens).toBe(150);
});
it("returns empty text when response has no choices", async () => {
const responseText = JSON.stringify({ choices: [] });
const { fn } = mockHttp({ text: responseText });
const provider = new OllamaProvider(fn);
const result = await provider.complete(makeRequest());
expect(result.text).toBe("");
});
it("sends content-type header", async () => {
const responseText = ollamaResponse("ok");
const { fn, calls } = mockHttp({ text: responseText });
const provider = new OllamaProvider(fn);
await provider.complete(makeRequest());
expect(calls[0].headers!["content-type"]).toBe("application/json");
});
it("includes Bearer token when apiKey is provided", async () => {
const responseText = ollamaResponse("ok");
const { fn, calls } = mockHttp({ text: responseText });
const provider = new OllamaProvider(fn);
await provider.complete(makeRequest({ apiKey: "remote-key-123" }));
expect(calls[0].headers!["Authorization"]).toBe("Bearer remote-key-123");
});
it("omits Authorization header when no apiKey", async () => {
const responseText = ollamaResponse("ok");
const { fn, calls } = mockHttp({ text: responseText });
const provider = new OllamaProvider(fn);
await provider.complete(makeRequest({ apiKey: undefined }));
expect(calls[0].headers!["Authorization"]).toBeUndefined();
});
it("throws on non-2xx response with error message", async () => {
const errorResponse = JSON.stringify({ error: "model not found" });
const { fn } = mockHttp({ status: 404, text: errorResponse });
const provider = new OllamaProvider(fn);
await expect(provider.complete(makeRequest())).rejects.toThrow("Ollama error (404)");
await expect(provider.complete(makeRequest())).rejects.toThrow("model not found");
});
it("throws on 500 response with raw text when not JSON", async () => {
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");
});
it("throws connection refused message when Ollama is not running", async () => {
const fn: HttpFn = async () => {
throw new Error("ECONNREFUSED");
};
const provider = new OllamaProvider(fn);
await expect(
provider.complete(makeRequest()),
).rejects.toThrow("Cannot connect to Ollama at http://localhost:11434. Is Ollama running?");
});
});
describe("estimateTokens", () => {
it("estimates tokens as words * 1.0", () => {
const { fn } = mockHttp();
const provider = new OllamaProvider(fn);
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 OllamaProvider(fn);
expect(provider.estimateTokens("")).toBe(0);
});
it("handles single word", () => {
const { fn } = mockHttp();
const provider = new OllamaProvider(fn);
expect(provider.estimateTokens("hello")).toBe(1);
});
it("ignores extra whitespace", () => {
const { fn } = mockHttp();
const provider = new OllamaProvider(fn);
expect(provider.estimateTokens(" spaced out text ")).toBe(3);
});
});
describe("testConnection", () => {
it("returns null on successful connection", async () => {
const text = tagsResponse(["llama3.2"]);
const { fn } = mockHttp({ status: 200, text });
const provider = new OllamaProvider(fn);
const result = await provider.testConnection({ endpoint: "http://localhost:11434" });
expect(result).toBeNull();
});
it("pings /api/tags endpoint", async () => {
const text = tagsResponse([]);
const { fn, calls } = mockHttp({ status: 200, text });
const provider = new OllamaProvider(fn);
await provider.testConnection({ endpoint: "http://localhost:11434" });
expect(calls).toHaveLength(1);
expect(calls[0].url).toBe("http://localhost:11434/api/tags");
expect(calls[0].method).toBe("GET");
});
it("returns error message on non-200 status", async () => {
const { fn } = mockHttp({ status: 500, text: "Internal Server Error" });
const provider = new OllamaProvider(fn);
const result = await provider.testConnection({ endpoint: "http://localhost:11434" });
expect(result).toContain("500");
expect(result).toContain("Ollama error");
});
it("returns error message when Ollama is not running", async () => {
const fn: HttpFn = async () => {
throw new Error("ECONNREFUSED");
};
const provider = new OllamaProvider(fn);
const result = await provider.testConnection({ endpoint: "http://localhost:11434" });
expect(result).toContain("Cannot connect to Ollama");
expect(result).toContain("http://localhost:11434");
expect(result).toContain("Is Ollama running?");
});
it("uses default endpoint when not specified", async () => {
const text = tagsResponse([]);
const { fn, calls } = mockHttp({ status: 200, text });
const provider = new OllamaProvider(fn);
await provider.testConnection({});
expect(calls[0].url).toBe("http://localhost:11434/api/tags");
});
it("uses custom endpoint from settings", async () => {
const text = tagsResponse([]);
const { fn, calls } = mockHttp({ status: 200, text });
const provider = new OllamaProvider(fn);
await provider.testConnection({ endpoint: "http://remote:8080" });
expect(calls[0].url).toBe("http://remote:8080/api/tags");
});
});
});
+94
View File
@@ -0,0 +1,94 @@
import { describe, it, expect, beforeEach } from "vitest";
import { ProviderRegistry } from "../../src/providers/registry";
import type { LLMService, ModelInfo, CompletionRequest, CompletionResponse, ProviderSettings } from "../../src/providers/service";
/** Minimal stub that satisfies LLMService for testing the registry. */
function makeStubProvider(name: string): LLMService {
return {
name,
requiresApiKey: false,
getModels: async (): Promise<ModelInfo[]> => [],
complete: async (req: CompletionRequest): Promise<CompletionResponse> => ({
text: "stub",
model: req.model,
provider: name,
}),
estimateTokens: (text: string) => text.split(/\s+/).length,
testConnection: async (_settings: ProviderSettings) => null,
};
}
describe("ProviderRegistry", () => {
let registry: ProviderRegistry;
beforeEach(() => {
registry = new ProviderRegistry();
});
it("starts empty", () => {
expect(registry.getAll()).toHaveLength(0);
expect(registry.getNames()).toHaveLength(0);
});
it("registers a provider and retrieves it by name", () => {
const provider = makeStubProvider("test-provider");
registry.register(provider);
const result = registry.get("test-provider");
expect(result).toBe(provider);
expect(result?.name).toBe("test-provider");
});
it("returns undefined for an unregistered name", () => {
expect(registry.get("nonexistent")).toBeUndefined();
});
it("registers multiple providers", () => {
registry.register(makeStubProvider("alpha"));
registry.register(makeStubProvider("beta"));
registry.register(makeStubProvider("gamma"));
expect(registry.getAll()).toHaveLength(3);
expect(registry.getNames()).toEqual(
expect.arrayContaining(["alpha", "beta", "gamma"]),
);
});
it("getAll returns all registered providers", () => {
const a = makeStubProvider("a");
const b = makeStubProvider("b");
registry.register(a);
registry.register(b);
const all = registry.getAll();
expect(all).toContain(a);
expect(all).toContain(b);
});
it("getNames returns all registered names", () => {
registry.register(makeStubProvider("anthropic"));
registry.register(makeStubProvider("ollama"));
const names = registry.getNames();
expect(names).toContain("anthropic");
expect(names).toContain("ollama");
});
it("overwrites an existing provider with the same name", () => {
const first = makeStubProvider("same-name");
const second = makeStubProvider("same-name");
registry.register(first);
registry.register(second);
expect(registry.getAll()).toHaveLength(1);
expect(registry.get("same-name")).toBe(second);
});
it("get is case-sensitive", () => {
registry.register(makeStubProvider("Anthropic"));
expect(registry.get("Anthropic")).toBeDefined();
expect(registry.get("anthropic")).toBeUndefined();
expect(registry.get("ANTHROPIC")).toBeUndefined();
});
});
+188
View File
@@ -0,0 +1,188 @@
import { describe, it, expect } from "vitest";
import { TAG_COMPLEXITY, getRoute, resolveModel } from "../../src/providers/router";
import type { RouteConfig, ComplexityTier } from "../../src/providers/router";
const DEFAULT_CONFIG: RouteConfig = {
light: { provider: "ollama", model: "llama3.2" },
standard: { provider: "anthropic", model: "claude-sonnet-4-6" },
heavy: { provider: "anthropic", model: "claude-opus-4-6" },
};
describe("TAG_COMPLEXITY", () => {
it("maps CUT to light", () => {
expect(TAG_COMPLEXITY["CUT"]).toBe("light");
});
it("maps PACING to light", () => {
expect(TAG_COMPLEXITY["PACING"]).toBe("light");
});
it("maps TONE to standard", () => {
expect(TAG_COMPLEXITY["TONE"]).toBe("standard");
});
it("maps EXPAND to standard", () => {
expect(TAG_COMPLEXITY["EXPAND"]).toBe("standard");
});
it("maps PLOT to standard", () => {
expect(TAG_COMPLEXITY["PLOT"]).toBe("standard");
});
it("maps REWRITE to heavy", () => {
expect(TAG_COMPLEXITY["REWRITE"]).toBe("heavy");
});
it("maps DIALOG to heavy", () => {
expect(TAG_COMPLEXITY["DIALOG"]).toBe("heavy");
});
it("maps CHARACTER to heavy", () => {
expect(TAG_COMPLEXITY["CHARACTER"]).toBe("heavy");
});
it("covers all expected tags", () => {
const expectedTags = ["CUT", "PACING", "TONE", "EXPAND", "PLOT", "REWRITE", "DIALOG", "CHARACTER"];
for (const tag of expectedTags) {
expect(TAG_COMPLEXITY).toHaveProperty(tag);
}
});
it("has only valid tier values", () => {
const validTiers: ComplexityTier[] = ["light", "standard", "heavy"];
for (const tier of Object.values(TAG_COMPLEXITY)) {
expect(validTiers).toContain(tier);
}
});
});
describe("getRoute", () => {
it("routes CUT to light tier provider+model", () => {
const route = getRoute("CUT", DEFAULT_CONFIG);
expect(route).toEqual({ provider: "ollama", model: "llama3.2" });
});
it("routes PACING to light tier", () => {
const route = getRoute("PACING", DEFAULT_CONFIG);
expect(route).toEqual({ provider: "ollama", model: "llama3.2" });
});
it("routes TONE to standard tier", () => {
const route = getRoute("TONE", DEFAULT_CONFIG);
expect(route).toEqual({ provider: "anthropic", model: "claude-sonnet-4-6" });
});
it("routes EXPAND to standard tier", () => {
const route = getRoute("EXPAND", DEFAULT_CONFIG);
expect(route).toEqual({ provider: "anthropic", model: "claude-sonnet-4-6" });
});
it("routes PLOT to standard tier", () => {
const route = getRoute("PLOT", DEFAULT_CONFIG);
expect(route).toEqual({ provider: "anthropic", model: "claude-sonnet-4-6" });
});
it("routes REWRITE to heavy tier", () => {
const route = getRoute("REWRITE", DEFAULT_CONFIG);
expect(route).toEqual({ provider: "anthropic", model: "claude-opus-4-6" });
});
it("routes DIALOG to heavy tier", () => {
const route = getRoute("DIALOG", DEFAULT_CONFIG);
expect(route).toEqual({ provider: "anthropic", model: "claude-opus-4-6" });
});
it("routes CHARACTER to heavy tier", () => {
const route = getRoute("CHARACTER", DEFAULT_CONFIG);
expect(route).toEqual({ provider: "anthropic", model: "claude-opus-4-6" });
});
it("falls back to standard for unknown tags", () => {
const route = getRoute("UNKNOWN_TAG", DEFAULT_CONFIG);
expect(route).toEqual({ provider: "anthropic", model: "claude-sonnet-4-6" });
});
it("falls back to standard for empty string tag", () => {
const route = getRoute("", DEFAULT_CONFIG);
expect(route).toEqual({ provider: "anthropic", model: "claude-sonnet-4-6" });
});
it("falls back to standard for NOTE (passthrough tag)", () => {
const route = getRoute("NOTE", DEFAULT_CONFIG);
expect(route).toEqual({ provider: "anthropic", model: "claude-sonnet-4-6" });
});
it("works with all-local config", () => {
const localConfig: RouteConfig = {
light: { provider: "ollama", model: "llama3.2" },
standard: { provider: "ollama", model: "mistral" },
heavy: { provider: "ollama", model: "deepseek-coder" },
};
expect(getRoute("CUT", localConfig)).toEqual({ provider: "ollama", model: "llama3.2" });
expect(getRoute("TONE", localConfig)).toEqual({ provider: "ollama", model: "mistral" });
expect(getRoute("REWRITE", localConfig)).toEqual({ provider: "ollama", model: "deepseek-coder" });
});
it("works with all-cloud config", () => {
const cloudConfig: RouteConfig = {
light: { provider: "anthropic", model: "claude-haiku-4-5" },
standard: { provider: "anthropic", model: "claude-sonnet-4-6" },
heavy: { provider: "anthropic", model: "claude-opus-4-6" },
};
expect(getRoute("CUT", cloudConfig)).toEqual({ provider: "anthropic", model: "claude-haiku-4-5" });
expect(getRoute("EXPAND", cloudConfig)).toEqual({ provider: "anthropic", model: "claude-sonnet-4-6" });
expect(getRoute("DIALOG", cloudConfig)).toEqual({ provider: "anthropic", model: "claude-opus-4-6" });
});
it("resolves auto-latest to tier-appropriate models", () => {
const autoConfig: RouteConfig = {
light: { provider: "anthropic", model: "auto-latest" },
standard: { provider: "anthropic", model: "auto-latest" },
heavy: { provider: "anthropic", model: "auto-latest" },
};
expect(getRoute("CUT", autoConfig)).toEqual({ provider: "anthropic", model: "claude-haiku-4-5" });
expect(getRoute("PACING", autoConfig)).toEqual({ provider: "anthropic", model: "claude-haiku-4-5" });
expect(getRoute("TONE", autoConfig)).toEqual({ provider: "anthropic", model: "claude-sonnet-4-6" });
expect(getRoute("EXPAND", autoConfig)).toEqual({ provider: "anthropic", model: "claude-sonnet-4-6" });
expect(getRoute("REWRITE", autoConfig)).toEqual({ provider: "anthropic", model: "claude-opus-4-6" });
expect(getRoute("DIALOG", autoConfig)).toEqual({ provider: "anthropic", model: "claude-opus-4-6" });
});
it("passes through non-auto-latest models unchanged", () => {
const config: RouteConfig = {
light: { provider: "anthropic", model: "claude-haiku-4-5" },
standard: { provider: "anthropic", model: "claude-sonnet-4-6" },
heavy: { provider: "anthropic", model: "auto-latest" },
};
expect(getRoute("CUT", config)).toEqual({ provider: "anthropic", model: "claude-haiku-4-5" });
expect(getRoute("REWRITE", config)).toEqual({ provider: "anthropic", model: "claude-opus-4-6" });
});
});
describe("resolveModel", () => {
it("resolves auto-latest for light tier to haiku", () => {
expect(resolveModel("auto-latest", "light")).toBe("claude-haiku-4-5");
});
it("resolves auto-latest for standard tier to sonnet", () => {
expect(resolveModel("auto-latest", "standard")).toBe("claude-sonnet-4-6");
});
it("resolves auto-latest for heavy tier to opus", () => {
expect(resolveModel("auto-latest", "heavy")).toBe("claude-opus-4-6");
});
it("resolves auto-latest for unknown tier to sonnet (default)", () => {
expect(resolveModel("auto-latest", "unknown")).toBe("claude-sonnet-4-6");
});
it("passes through specific model names unchanged", () => {
expect(resolveModel("claude-sonnet-4-6", "light")).toBe("claude-sonnet-4-6");
expect(resolveModel("claude-opus-4-6", "standard")).toBe("claude-opus-4-6");
expect(resolveModel("llama3.2", "heavy")).toBe("llama3.2");
});
it("passes through empty string unchanged", () => {
expect(resolveModel("", "standard")).toBe("");
});
});
+91
View File
@@ -0,0 +1,91 @@
import { describe, it, expect } from "vitest";
import { globMatch } from "../src/utils";
describe("globMatch", () => {
describe("exact match", () => {
it("matches an exact string", () => {
expect(globMatch("hello.md", "hello.md")).toBe(true);
});
it("rejects a non-matching string", () => {
expect(globMatch("hello.md", "goodbye.md")).toBe(false);
});
it("is case-sensitive", () => {
expect(globMatch("Hello.md", "hello.md")).toBe(false);
});
});
describe("* wildcard", () => {
it("matches any sequence of characters", () => {
expect(globMatch("ch-*.md", "ch-01.md")).toBe(true);
expect(globMatch("ch-*.md", "ch-99.md")).toBe(true);
expect(globMatch("ch-*.md", "ch-abc.md")).toBe(true);
});
it("matches empty sequence", () => {
expect(globMatch("ch-*.md", "ch-.md")).toBe(true);
});
it("matches multiple * wildcards", () => {
expect(globMatch("*-*-*.md", "a-b-c.md")).toBe(true);
expect(globMatch("*.*.*", "a.b.c")).toBe(true);
});
it("matches * at the beginning", () => {
expect(globMatch("*.md", "anything.md")).toBe(true);
});
it("matches * at the end", () => {
expect(globMatch("ch-01.*", "ch-01.md")).toBe(true);
expect(globMatch("ch-01.*", "ch-01.txt")).toBe(true);
});
it("matches * for entire string", () => {
expect(globMatch("*", "anything")).toBe(true);
expect(globMatch("*", "")).toBe(true);
});
it("rejects when non-wildcard parts do not match", () => {
expect(globMatch("ch-*.md", "ch-01.txt")).toBe(false);
expect(globMatch("ch-*.md", "xx-01.md")).toBe(false);
});
});
describe("? wildcard", () => {
it("matches a single character", () => {
expect(globMatch("ch-0?.md", "ch-01.md")).toBe(true);
expect(globMatch("ch-0?.md", "ch-09.md")).toBe(true);
});
it("does not match zero characters", () => {
expect(globMatch("ch-0?.md", "ch-0.md")).toBe(false);
});
it("does not match multiple characters", () => {
expect(globMatch("ch-?.md", "ch-01.md")).toBe(false);
});
});
describe("edge cases", () => {
it("handles empty pattern and text", () => {
expect(globMatch("", "")).toBe(true);
});
it("handles empty pattern with non-empty text", () => {
expect(globMatch("", "hello")).toBe(false);
});
it("handles pattern of only wildcards", () => {
expect(globMatch("***", "hello")).toBe(true);
expect(globMatch("???", "abc")).toBe(true);
expect(globMatch("???", "ab")).toBe(false);
});
it("matches the default chapter file pattern", () => {
expect(globMatch("ch-*.md", "ch-01.md")).toBe(true);
expect(globMatch("ch-*.md", "ch-01.v2.md")).toBe(true);
expect(globMatch("ch-*.md", "outline.md")).toBe(false);
});
});
});
+20 -1
View File
@@ -107,10 +107,29 @@ describe("readState", () => {
],
});
const state = readState(json);
expect(state.processedAnnotations).toHaveLength(2);
// Non-object entries ("not an object") should be filtered out
expect(state.processedAnnotations).toHaveLength(1);
// Should coerce to strings/defaults.
expect(state.processedAnnotations[0].hash).toBe("123");
});
it("filters out null, number, and string entries from processedAnnotations", () => {
const json = JSON.stringify({
version: 2,
processedAnnotations: [
null,
42,
"string entry",
true,
{ hash: "valid123", tag: "REWRITE", line: 5, processedInVersion: 1 },
undefined,
],
});
const state = readState(json);
// Only the valid object should remain
expect(state.processedAnnotations).toHaveLength(1);
expect(state.processedAnnotations[0].hash).toBe("valid123");
});
});
describe("updateState", () => {