Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,8 @@ Notable changes to Papilot, newest first. The format follows [Keep a Changelog](
- **Claude on your Claude plan.** With Claude Code installed and signed in, Claude tasks run `claude -p` on that login, so a Claude plan works without an API key. Edits in the worktree are accepted, commands run in Claude Code's sandbox, anything that would need approval is declined and noted, and each task resumes its session. Without Claude Code, Claude still runs through the Agent SDK with an API key.

- **More agents, found automatically.** Cursor (through the Cursor SDK, with `CURSOR_API_KEY`), GLM (Claude Code on Zhipu's Anthropic-compatible endpoint, with `ZHIPU_API_KEY` or `ZAI_API_KEY`), DeepSeek (`dsh acp` from DeepSeek Harness), Qwen Code (`qwen --acp`) and OpenCode (`opencode acp`) join the agent menu. Papilot offers only the agents it finds on the host, looks again on **Reconnect**, and names the rest in Settings → Runtime & diagnostics. DeepSeek keeps its workspace sandbox, Qwen runs in Auto Edit mode, and OpenCode asks before commands and anything outside the worktree. `papilot doctor` checks each of them and lists agents that are not set up for information only.

- **Bibliography checks.** A change to a `.bib` file is reviewed entry by entry, with every field and where the paper cites each entry; hunks of one entry are decided together. Papilot looks up added and modified entries in Crossref, DBLP and arXiv (DOIs through doi.org) and marks each as verified, differing from its record or not verified, with the record beside it. Accepting an entry that is not verified asks first, and **Accept all** leaves it for you. Removed entries the paper still cites, duplicate keys and new citations without an entry are flagged too, and agents are told never to invent references. Turn the lookups off in **Settings → Check references online**.
- **Reference check.** Under **More** in the sidebar, check the whole bibliography: citations without an entry, duplicate keys, the same work under two keys, uncited entries, and each entry against Crossref, DBLP and arXiv. Mark an entry no source knows as **I checked it myself**, which reviews then honour, and ask an agent to find out what happened with the rest, in a new task you can edit first; it reports and changes nothing.
- **Kimi Code support.** Choose Kimi for paper-editing tasks using your local Kimi Code CLI login. The ACP adapter supports model and thinking-level selection, streamed replies, image inputs, tool approvals, cancellation and session recovery. Diagnostics include Kimi CLI availability; set `PAPILOT_KIMI_BIN` for a custom executable path.

### Changed
Expand Down
6 changes: 5 additions & 1 deletion docs/guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,10 @@ Titles (the brand, paper names, task and page headings) use the manuscript's TeX

The current review shows action bars and map markers only for pending changes. Accepted changes show the clean revised PDF; rejected changes show the clean original. Commented changes leave the pending list while their feedback waits to be sent. If one PDF region contains a mix of accepted and rejected edits, open **History** to inspect it; the viewer does not pretend the entire region was accepted. **History** shows the diffs and comments from any round. **Undo last decision** restores the most recent decision in the latest round, returning those changes to the current review. Earlier rounds are read-only. After sending feedback, the viewer shows the agent's progress; if the agent makes no new edits, the completed review stays clear of old Change bars.

**Bibliography changes** are reviewed entry by entry rather than as source lines. Each entry a change adds, modifies or removes in a `.bib` file appears whole under that change: every field with its value as written (a modified field shows its old and new value), the lines it occupies, and where the paper cites it. Hunks that touch the same entry make one change, so an entry is never half accepted; the raw source stays under **LaTeX**. Papilot then looks up each added or modified entry online: a DOI through doi.org, an arXiv identifier through arXiv, and otherwise the title and authors in Crossref, DBLP and arXiv. An entry is **Verified** when a record agrees in title, authors and year, **Differs from the record** when the same work has other details or its DOI names another work (the differing fields are listed against the record's, with a link to it), **Not verified** when no record matches, and **Could not check** when no source could be reached. Not verified is not proof that a work does not exist: theses, reports and many books are in none of these sources. A preprint's year before the published version's, or a subtitle, counts as a small difference only. A source that turns requests away, as DBLP's bot check sometimes does, is skipped, and the entry names it. The **Bibliography** line above the review counts the entries and lists those that need a look, together with removed entries the paper still cites, keys defined twice, entries in a `.bib` file the paper does not read, and new citations with no entry. Accepting (or staging) a change with such an entry asks first, and **Accept all** leaves those changes for you to decide one by one. An entry no source knows, such as a book or a thesis, can be marked **I checked it myself**: it then counts as checked in every later review, until its title, authors, year or identifiers change. Only an entry's title, authors, year and identifiers are sent; turn the lookups off with **Settings → Check references online**, which applies to this browser. Results are kept for a month, or a day when nothing matched, in the repository's `.git/latex-review/references.json`, and in the data folder for uncommitted changes. Agents are told never to write an entry from memory and to include its DOI or arXiv identifier.

**Reference check**, under **More** below **Manuscript** in the sidebar, checks the paper's whole bibliography as it is on disk: the `.bib` files named by `\bibliography` or `\addbibresource`, and the citations in every file the paper inputs. Without looking anything up it lists citations of keys no entry defines, keys defined twice, the same work under two keys (same DOI or title), entries the paper never cites (unless it uses `\nocite{*}`) and entries BibTeX cannot read. **Check online** compares each entry with Crossref, DBLP and arXiv as reviews do; results are kept, so later checks look up only new or edited entries, and **Check again** retries those that found nothing. **Problems** lists what needs a look, **All entries** every entry; open one to see all its fields, where it is cited, and the record it was compared with. **I checked it myself** works here as in reviews. **Ask an agent to look into** opens the manuscript's new task with the ticked problems in its prompt, which you can edit before sending. The agent is asked to change nothing and to find out what happened with each: whether the work exists and what its published record says, where the entry or citation came from in the Git history, and what it would suggest, marking which findings it confirmed in a source. What to change stays your decision. **More** opens its tools beside the sidebar (a sheet on a phone); once the check has run, the menu shows how many problems it found.

Conversation drafts are saved per task in this browser. Sending review comments preserves an unsent draft, and text entered while a message is sending stays in the composer. Switching tasks preserves pending submissions and unfinished Codex answers. If browser storage is unavailable, the workspace remains usable and drafts stay in the current tab until it closes or reloads. Press Enter to send and Shift+Enter for a new line. On touch keyboards, Enter adds a new line; use the send button. **Command+Enter** on macOS or **Ctrl+Enter** elsewhere always sends. Input-method composition is never sent by Enter. The new-task prompt is also saved in this browser until the task is created. Agent replies render Markdown, including tables, task lists, nested lists, fenced code and LaTeX mathematics (`$…$`, `$$…$$`, `\(…\)` and `\[…\]`, typeset with KaTeX, which loads the first time a reply contains mathematics). Your own messages show what you did: a skill as its name with your words, sent review feedback as its decisions and comments, and PDF notes as the notes, never the prompt the agent receives. Local file references copy their full path. The **Add a paper** and delete dialogs keep keyboard focus inside them; **Escape** closes them when no submission is in progress.

The mobile task drawer keeps keyboard focus inside it and closes with **Escape**, returning focus to its opener. Long conversations follow new messages until you scroll up; **Latest messages** returns to the bottom. Connection loss is shown above the workspace even when the sidebar is hidden. Failed conversation and PDF review loads offer their own retry controls, so your instruction draft stays in place.
Expand Down Expand Up @@ -309,6 +313,6 @@ To release, set the version in `package.json` and `package-lock.json`, give it a

## Review engine

`vendor/latex-review` is based on the existing MIT-licensed `latex-review-skill`. Its upstream revision is recorded in `UPSTREAM_COMMIT`; its license and source are preserved. A small viewer integration honors Papilot's decision availability state. Papilot's copy also compares the whole PDFs to list the places an edit changes away from its own lines, as its reference describes. Papilot uses the engine's public HTTP interface and does not write its SQLite database. The embedded review gateway only exposes review assets and review decisions, not shutdown or task-deletion endpoints.
`vendor/latex-review` is based on the existing MIT-licensed `latex-review-skill`. Its upstream revision is recorded in `UPSTREAM_COMMIT`; its license and source are preserved. A small viewer integration honors Papilot's decision availability state. Papilot's copy also compares the whole PDFs to list the places an edit changes away from its own lines, as its reference describes. It also reads `.bib` changes entry by entry and checks added and modified entries online (`src/core/bib.ts`, `src/core/verify.ts`, and `GET /api/tasks/:name/rounds/:number/references`). Papilot uses the engine's public HTTP interface and does not write its SQLite database. The embedded review gateway only exposes review assets and review decisions, not shutdown or task-deletion endpoints.

See [REQUIREMENTS.md](../REQUIREMENTS.md) for the initial scope and acceptance criteria.
21 changes: 20 additions & 1 deletion src/server/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import type { Workspace } from './workspace.ts';
import { reviewClient, allowedReviewRoute, rewriteReviewHtml, rewriteReviewJson, confirmedReviewResponse, annotateReviewState } from './review.ts';
import { fullPaperPdf } from './pdf.ts';
import { type ChangeAction, Changes } from './changes.ts';
import { bibliography, confirmEntry } from './references.ts';
import { type Manuscripts, annotationTask, gitAbortMerge, gitCommit, gitDiff, gitFinishMerge, gitIgnore, gitRemote, gitStage, gitStatus } from './manuscript.ts';
import { gitOverview } from './git-overview.ts';
import { findSkill, listedSkill, loadSkills } from './skills.ts';
Expand Down Expand Up @@ -263,6 +264,14 @@ export async function buildApp(options: AppOptions) {
// The manuscript preview compiles each paper's working tree and reports its Git status.
app.get<{ Params: { id: string } }>('/api/projects/:id/manuscript', project(), request => manuscript(request.params.id));
app.get<{ Params: { id: string } }>('/api/projects/:id/git', project(), request => gitStatus(store.project(request.params.id).path));
// The paper's bibliography: every entry, its problems and its online check.
app.get<{ Params: { id: string }; Querystring: { check?: string; retry?: string } }>('/api/projects/:id/references', project(), request =>
bibliography(store.project(request.params.id).path, request.query.check === '1', request.query.retry === '1'));
app.post<{ Params: { id: string } }>('/api/projects/:id/references/confirm', project(), request => {
const b = request.body as any;
if (typeof b?.id !== 'string' || typeof b?.confirmed !== 'boolean') throw new Error('Choose an entry to confirm.');
return confirmEntry(store.project(request.params.id).path, b.id, b.confirmed);
});
app.get<{ Params: { id: string } }>('/api/projects/:id/git/overview', project(), request => gitOverview(store.project(request.params.id).path, store.tasks().filter(task => task.projectId === request.params.id)));
app.get<{ Params: { id: string }; Querystring: { path?: string } }>('/api/projects/:id/git/diff', project(), request => gitDiff(store.project(request.params.id).path, string(request.query.path, 'File path', 4096)));
const paths = (value: unknown): string[] => Array.isArray(value) ? value.filter((p: unknown): p is string => typeof p === 'string').slice(0, 5000) : [];
Expand Down Expand Up @@ -427,6 +436,16 @@ export async function buildApp(options: AppOptions) {
app.get<{ Params: { projectId: string; number: string } }>('/review/:projectId/api/changes/rounds/:number', changesAccess, request => changes.detail(store.project(request.params.projectId).id, Number(request.params.number)));
app.get<{ Params: { projectId: string; number: string; unit: string } }>('/review/:projectId/api/changes/rounds/:number/effects/:unit', changesAccess, request =>
changes.effects(store.project(request.params.projectId).id, Number(request.params.number), request.params.unit));
app.get<{ Params: { projectId: string; number: string }; Querystring: { check?: string; retry?: string } }>('/review/:projectId/api/changes/rounds/:number/references', changesAccess, request => {
const project = store.project(request.params.projectId);
return changes.references(project.id, project.path, Number(request.params.number), request.query.check === '1', request.query.retry === '1');
});
app.post<{ Params: { projectId: string; number: string } }>('/review/:projectId/api/changes/rounds/:number/references/confirm', changesAccess, request => {
changesToken(request);
const project = store.project(request.params.projectId), b = request.body as any;
if (typeof b?.id !== 'string' || typeof b?.confirmed !== 'boolean') throw new Error('Choose an entry to confirm.');
return changes.confirmReference(project.id, project.path, Number(request.params.number), b.id, b.confirmed);
});
app.get<{ Params: { projectId: string; number: string; file: string } }>('/review/:projectId/changes/files/:number/:file', changesAccess, (request, reply) => {
const file = changes.file(store.project(request.params.projectId).id, Number(request.params.number), request.params.file);
return reply.header('Cache-Control', 'private, max-age=31536000, immutable').type(file.endsWith('.svg') ? 'image/svg+xml' : 'image/png').send(fs.createReadStream(file));
Expand Down Expand Up @@ -475,7 +494,7 @@ export async function buildApp(options: AppOptions) {
const task = store.tasks().find(t => t.projectId === project.id && t.name === route.split('/')[3]);
if (task) store.updateTask(task.id, { error: 'Review action failed: ' + (value as any).error });
}
if (response.ok && request.method === 'POST') {
if (response.ok && request.method === 'POST' && !route.endsWith('/references/confirm')) {
const task = store.tasks().find(t => t.projectId === project.id && t.name === route.split('/')[3]);
if (task) {
store.updateTask(task.id, { error: null });
Expand Down
27 changes: 25 additions & 2 deletions src/server/changes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { pathToFileURL } from 'node:url';
import { DATA, ENGINE } from './paths.ts';
import { mergeInProgress, workingTree } from './manuscript.ts';
import { ignorePattern } from '../commit.ts';
import { checker, confirmations, engine as referenceEngine } from './references.ts';

// The manuscript's uncommitted changes as a PDF comparison: the last commit
// against the working tree, rendered by the review engine like a task's round
Expand Down Expand Up @@ -197,14 +198,36 @@ export class Changes {
detail(projectId: string, number: number) {
const round = this.comparison(projectId).rounds.find(item => item.number === number);
if (!round) throw Object.assign(new Error('This comparison is out of date. Refresh the page.'), { statusCode: 404 });
let render: { units: Record<string, object>; sections: unknown[]; loose: unknown[] } = { units: {}, sections: [], loose: [] };
try { render = JSON.parse(fs.readFileSync(path.join(round.folder, 'round.json'), 'utf8')); } catch { /* An empty comparison has no render. */ }
const render = this.rendered(round);
return {
task: 'changes', round: round.number, before: round.before, after: round.after,
units: round.units.map(unit => ({ ...unit, ...render.units[unit.id] })), sections: render.sections, loose: render.loose,
warnings: round.warnings, files: `/review/${projectId}/changes/files/${round.number}/`,
...(render.references ? { references: render.references } : {}),
};
}
private rendered(round: Round): { units: Record<string, object>; sections: unknown[]; loose: unknown[]; references?: unknown } {
try { return JSON.parse(fs.readFileSync(path.join(round.folder, 'round.json'), 'utf8')); } catch { return { units: {}, sections: [], loose: [] }; } // An empty comparison has no render.
}

// Online checks of the bibliography entries the uncommitted changes add or
// modify, as for a task's round; check asks for lookups to start.
async references(projectId: string, root: string, number: number, check: boolean, retry: boolean) {
const inputs = await this.entryInputs(projectId, number);
return { ...(await checker()).lookup(inputs, check, retry), confirmed: (await confirmations(root)).of(inputs) };
}
// The author checked an entry by hand, or takes that back.
async confirmReference(projectId: string, root: string, number: number, id: string, on: boolean) {
const inputs = await this.entryInputs(projectId, number), input = inputs.find(item => item.id === id);
if (!input) throw Object.assign(new Error('This entry is no longer among the changes. Refresh the page.'), { statusCode: 404 });
(await confirmations(root)).set(input, on);
return this.references(projectId, root, number, false, false);
}
private async entryInputs(projectId: string, number: number) {
const round = this.comparison(projectId).rounds.find(item => item.number === number);
if (!round) throw Object.assign(new Error('This comparison is out of date. Refresh the page.'), { statusCode: 404 });
return (await referenceEngine()).checkInputs(this.rendered(round).references);
}

// The places a change alters away from its own lines, past those the
// comparison rendered, rendered into its folder when first asked for.
Expand Down
Loading
Loading