Compare commits
16
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
06dec906bf | ||
|
|
db532bef96 | ||
|
|
01f042a41b | ||
|
|
e20e29d2d0 | ||
|
|
e026d72753 | ||
|
|
036a58b30c | ||
|
|
6f70b3a93c | ||
|
|
1e90a415cd | ||
|
|
6dd234d21e | ||
|
|
b0369c0bdc | ||
|
|
91518ea936 | ||
|
|
ad31277025 | ||
|
|
9369bc89cc | ||
|
|
d52ce01d92 | ||
|
|
9ca2192ed0 | ||
|
|
f35793f00e |
No files matched your search
@@ -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
|
||||
@@ -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:
|
||||
@@ -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? -->
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
@@ -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.
|
||||
@@ -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.
|
||||
|
||||

|
||||
<!-- 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
@@ -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.
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
|
||||
Generated
+939
-1022
File diff suppressed because it is too large.
Load diff
+2
-2
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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.
|
||||
@@ -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 = [
|
||||
"---",
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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("");
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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", () => {
|
||||
|
||||
Reference in new issue
Block a user