papilot 0.7.0 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +52 -30
- package/README.zh-CN.md +54 -30
- package/bin/doctor.mjs +53 -6
- package/bin/installation.d.mts +2 -0
- package/bin/installation.mjs +7 -1
- package/dist/assets/{_virtual_papilot-licenses-BWIsfrQh.js → _virtual_papilot-licenses-D7ro6ecN.js} +54 -3
- package/dist/assets/index-Cc-awdNn.css +1 -0
- package/dist/assets/index-D_VX51no.js +265 -0
- package/dist/assets/latex-BarqJFtQ.svg +1 -0
- package/dist/assets/{math-BsEl3u6P.js → math-V9uUaUzA.js} +1 -1
- package/dist/assets/pdf-view-Bnb2uIBE.js +598 -0
- package/dist/index.html +2 -2
- package/examples/papilot-paper/main.tex +1 -1
- package/examples/papilot-paper/sections/02-using.tex +3 -1
- package/examples/papilot-paper/sections/09-agents.tex +8 -5
- package/package.json +10 -6
- package/skills/ai-review.md +18 -0
- package/skills/latex-warnings.md +1 -1
- package/src/conversation.ts +6 -0
- package/src/server/acp.ts +354 -0
- package/src/server/agents.ts +220 -43
- package/src/server/ai-review-routes.ts +72 -0
- package/src/server/ai-review.ts +38 -0
- package/src/server/app.ts +53 -11
- package/src/server/build-problems.ts +329 -0
- package/src/server/changes.ts +25 -2
- package/src/server/codex.ts +2 -1
- package/src/server/cursor.ts +153 -0
- package/src/server/latex-installation.ts +43 -0
- package/src/server/manuscript.ts +165 -23
- package/src/server/paths.ts +1 -1
- package/src/server/references.ts +70 -0
- package/src/server/review.ts +3 -3
- package/src/server/skills.ts +5 -8
- package/src/server/store.ts +20 -2
- package/src/server/update-install.ts +53 -2
- package/src/server/update-worker.ts +6 -6
- package/src/server/updates.ts +43 -14
- package/src/server/workspace.ts +183 -29
- package/src/shared.ts +78 -7
- package/src/skill-icons.ts +18 -0
- package/src/skill-metadata.ts +45 -0
- package/vendor/latex-review/dist/web/assets/index-7VKAte5x.js +19 -0
- package/vendor/latex-review/dist/web/assets/index-DEz-LsGl.css +1 -0
- package/vendor/latex-review/dist/web/index.html +2 -2
- package/vendor/latex-review/src/core/bib.ts +692 -0
- package/vendor/latex-review/src/core/changes.ts +39 -0
- package/vendor/latex-review/src/core/review.ts +1 -0
- package/vendor/latex-review/src/core/verify.ts +784 -0
- package/vendor/latex-review/src/core/view.ts +204 -0
- package/vendor/latex-review/src/render/round.ts +11 -0
- package/vendor/latex-review/src/server.ts +65 -0
- package/vendor/latex-review/src/types.ts +125 -0
- package/vendor/latex-review/src/web/effect-preferences.ts +10 -0
- package/vendor/latex-review/src/web/references.tsx +499 -0
- package/vendor/latex-review/src/web/review.tsx +259 -131
- package/vendor/latex-review/src/web/style.css +285 -0
- package/vendor/latex-review/src/web/zh-CN.ts +75 -0
- package/dist/assets/index-CIJEYlP7.js +0 -252
- package/dist/assets/index-hOJEruFc.css +0 -1
- package/dist/assets/pdf-view-Cz9_vK5U.js +0 -598
- package/vendor/latex-review/dist/web/assets/index-Be9CtgcZ.css +0 -1
- package/vendor/latex-review/dist/web/assets/index-TvMWVSaw.js +0 -19
package/dist/index.html
CHANGED
|
@@ -8,6 +8,6 @@
|
|
|
8
8
|
document.documentElement.dataset.theme = dark ? 'dark' : 'light';
|
|
9
9
|
document.querySelector('meta[name="theme-color"]').content = dark ? '#26272b' : '#ffffff';
|
|
10
10
|
})();
|
|
11
|
-
</script><style>html{--lightningcss-light:initial;--lightningcss-dark: ;color-scheme:light;background:#fff}html[data-theme=dark]{--lightningcss-light: ;--lightningcss-dark:initial;color-scheme:dark;background:#26272b}</style> <script type="module" crossorigin src="/assets/index-
|
|
12
|
-
<link rel="stylesheet" crossorigin href="/assets/index-
|
|
11
|
+
</script><style>html{--lightningcss-light:initial;--lightningcss-dark: ;color-scheme:light;background:#fff}html[data-theme=dark]{--lightningcss-light: ;--lightningcss-dark:initial;color-scheme:dark;background:#26272b}</style> <script type="module" crossorigin src="/assets/index-D_VX51no.js"></script>
|
|
12
|
+
<link rel="stylesheet" crossorigin href="/assets/index-Cc-awdNn.css">
|
|
13
13
|
</head><body><div id="root"></div></body></html>
|
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
Papilot is a personal workspace for revising \LaTeX{} papers with an AI agent
|
|
13
13
|
and reviewing every resulting change as a PDF difference before it reaches the
|
|
14
14
|
manuscript. Each editing task runs in an isolated Git worktree, may be driven
|
|
15
|
-
by Codex, Claude or
|
|
15
|
+
by Codex, Claude, Antigravity or Kimi Code, and proceeds in review rounds in which the
|
|
16
16
|
author accepts, rejects or comments on individual changes. This paper describes
|
|
17
17
|
the system through a sequence of small formal models. We define edits and
|
|
18
18
|
review rounds as operators on a document, prove that compatible edits commute
|
|
@@ -91,7 +91,8 @@ remote (\cref{sec:git}).
|
|
|
91
91
|
\subsection{Agents}
|
|
92
92
|
|
|
93
93
|
Each task runs on one agent: Codex through its app server, Claude through the
|
|
94
|
-
Claude Agent SDK,
|
|
94
|
+
Claude Agent SDK, Antigravity through its command line, or Kimi Code through
|
|
95
|
+
ACP. For Kimi, install the CLI and run \texttt{kimi login}. Every agent uses its
|
|
95
96
|
own login on the host, and an agent that is not installed or signed in is listed
|
|
96
97
|
as unavailable. When an agent reports usage, the new-task page shows each usage
|
|
97
98
|
window with how much is used and when it resets (\cref{sec:usage}).
|
|
@@ -124,6 +125,7 @@ Variable & Purpose \\
|
|
|
124
125
|
\texttt{PAPILOT\_DATA} & state and generated example, \texttt{\textasciitilde/.papilot} by default \\
|
|
125
126
|
\texttt{PAPILOT\_CODEX\_BIN} & Codex executable \\
|
|
126
127
|
\texttt{ANTHROPIC\_API\_KEY} & API key for Claude tasks \\
|
|
128
|
+
\texttt{PAPILOT\_KIMI\_BIN} & Kimi Code executable \\
|
|
127
129
|
\texttt{PAPILOT\_AGY\_BIN} & Antigravity command line \\
|
|
128
130
|
\texttt{PAPILOT\_AGY\_PROXY} & HTTP proxy used only for Antigravity \\
|
|
129
131
|
\texttt{PAPILOT\_REVIEW\_ENGINE} & review engine directory \\
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
\section{Agents and scheduling}
|
|
2
2
|
\label{sec:agents}
|
|
3
3
|
|
|
4
|
-
\subsection{One protocol for
|
|
4
|
+
\subsection{One protocol for every agent}
|
|
5
5
|
|
|
6
6
|
Every agent is driven through the same small protocol: a thread is started or
|
|
7
7
|
resumed, a turn is started with the author's text, the agent streams items
|
|
@@ -40,8 +40,9 @@ task in a recoverable state with a \emph{Check status} action (not shown).}
|
|
|
40
40
|
\end{figure}
|
|
41
41
|
|
|
42
42
|
Codex speaks this protocol natively. Claude runs through the Claude Agent SDK,
|
|
43
|
-
|
|
44
|
-
|
|
43
|
+
Antigravity runs the \texttt{agy} command line once per turn, and Kimi Code
|
|
44
|
+
runs \texttt{kimi acp}; adapters translate their interfaces into the same
|
|
45
|
+
requests and notifications. Kimi sessions resume after a restart. Each
|
|
45
46
|
agent uses its own login on the host, and Papilot never reads, stores or
|
|
46
47
|
forwards a credential. An agent that cannot ask for permission, such as a
|
|
47
48
|
headless command line, is told in advance which tools it may not use.
|
|
@@ -102,5 +103,7 @@ answers after a mean delay $\Theta$, the expected wall-clock time of the turn is
|
|
|
102
103
|
\E[T] = T_w \bigl(1 + \zeta \Theta\bigr),
|
|
103
104
|
\label{eq:approvals}
|
|
104
105
|
\end{equation}
|
|
105
|
-
which
|
|
106
|
-
|
|
106
|
+
which motivates reducing unnecessary approval requests. Codex and Claude can
|
|
107
|
+
run sandboxed commands under their permission policies; Kimi uses manual
|
|
108
|
+
approvals for tools on the host, without a Papilot sandbox. Antigravity edits
|
|
109
|
+
files but cannot run shell commands in Papilot.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "papilot",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.8.0",
|
|
4
4
|
"description": "A Cursor-like workspace for your papers: annotate and review PDFs directly without ever touching LaTeX.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"latex",
|
|
@@ -12,17 +12,19 @@
|
|
|
12
12
|
"codex",
|
|
13
13
|
"claude",
|
|
14
14
|
"antigravity",
|
|
15
|
+
"kimi",
|
|
16
|
+
"kimi-code",
|
|
15
17
|
"writing",
|
|
16
18
|
"academic",
|
|
17
19
|
"manuscript"
|
|
18
20
|
],
|
|
19
|
-
"homepage": "https://
|
|
21
|
+
"homepage": "https://papilot.ai",
|
|
20
22
|
"repository": {
|
|
21
23
|
"type": "git",
|
|
22
|
-
"url": "git+https://github.com/
|
|
24
|
+
"url": "git+https://github.com/PapilotAI/papilot.git"
|
|
23
25
|
},
|
|
24
26
|
"bugs": {
|
|
25
|
-
"url": "https://github.com/
|
|
27
|
+
"url": "https://github.com/PapilotAI/papilot/issues"
|
|
26
28
|
},
|
|
27
29
|
"license": "MIT",
|
|
28
30
|
"author": "Cui Chen",
|
|
@@ -34,6 +36,8 @@
|
|
|
34
36
|
"bin",
|
|
35
37
|
"src/server",
|
|
36
38
|
"src/shared.ts",
|
|
39
|
+
"src/skill-icons.ts",
|
|
40
|
+
"src/skill-metadata.ts",
|
|
37
41
|
"src/notes.ts",
|
|
38
42
|
"src/note-groups.ts",
|
|
39
43
|
"src/conversation.ts",
|
|
@@ -64,9 +68,9 @@
|
|
|
64
68
|
"doctor": "node bin/papilot.mjs doctor",
|
|
65
69
|
"service:install": "tsx scripts/service.ts install",
|
|
66
70
|
"service:stop": "tsx scripts/service.ts stop",
|
|
67
|
-
"test:ui": "npm run build && node scripts/check-ui.mjs && node scripts/check-updates.mjs && node scripts/check-papers.mjs && node scripts/check-imports.mjs && node scripts/check-compilation.mjs && node scripts/check-review-notes.mjs && node --import tsx scripts/check-note-groups.mjs && node scripts/check-note-selection.mjs && node --import tsx scripts/check-note-results.mjs && node --import tsx scripts/check-review-flow.mjs && node scripts/check-skills.mjs && node --import tsx scripts/check-skill-library.mjs && node scripts/check-attachments.mjs && node --import tsx scripts/check-accounts.mjs",
|
|
71
|
+
"test:ui": "npm run build && node scripts/check-ui.mjs && node scripts/check-appearance.mjs && node scripts/check-updates.mjs && node scripts/check-papers.mjs && node scripts/check-imports.mjs && node scripts/check-compilation.mjs && node scripts/check-build-problems.mjs && node scripts/check-review-notes.mjs && node --import tsx scripts/check-note-groups.mjs && node scripts/check-note-selection.mjs && node scripts/check-ai-review.mjs && node --import tsx scripts/check-note-results.mjs && node --import tsx scripts/check-review-flow.mjs && node scripts/check-skills.mjs && node --import tsx scripts/check-skill-library.mjs && node scripts/check-attachments.mjs && node --import tsx scripts/check-accounts.mjs",
|
|
68
72
|
"site": "node scripts/build-site.mjs",
|
|
69
|
-
"site
|
|
73
|
+
"test:site": "npm run site && node scripts/check-site.mjs",
|
|
70
74
|
"prepack": "npm run build && npm run setup:review"
|
|
71
75
|
},
|
|
72
76
|
"dependencies": {
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: AI review
|
|
3
|
+
description: Read the paper and leave PDF comments without editing the manuscript
|
|
4
|
+
hint: What should the reviewer focus on? Leave empty for the whole paper
|
|
5
|
+
order: 8
|
|
6
|
+
output: annotations
|
|
7
|
+
---
|
|
8
|
+
Review the paper as a constructive academic reviewer. Read it and leave suggestions; do not edit, rewrite, commit, or apply changes to the manuscript.
|
|
9
|
+
|
|
10
|
+
- Follow the author's requested focus, or review the whole paper when none is given.
|
|
11
|
+
- Look for unsupported claims, gaps in reasoning, unclear definitions, inconsistent terminology, and concrete writing issues.
|
|
12
|
+
- Anchor each comment to the smallest relevant passage in the supplied PDF: the specific sentence, claim, or equation at issue, usually 1–3 lines. Avoid whole-paragraph or page-level highlights. If the issue concerns a symbol within a line, name that symbol in the comment.
|
|
13
|
+
- Write one issue per comment, usually in one short sentence: aim for 10–30 English words or 20–60 Chinese characters. State the problem and a concrete next step; add a second short sentence only when needed for accuracy. Example: “This bound is undefined at p = 1; state that case separately.”
|
|
14
|
+
- Omit praise, preambles, repeated quotations, background explanations, full derivations, and rewritten paragraphs. For a complex issue, identify the failing step and needed clarification; leave extended reasoning for follow-up.
|
|
15
|
+
- Prefer a few high-impact, actionable comments to minor or speculative criticism. Flag a repeated issue once at a representative location. Do not invent sources, results, or assumptions, or claim a citation is wrong without evidence.
|
|
16
|
+
- Use the author's requested language, otherwise the manuscript's language.
|
|
17
|
+
- The supplied PDF extraction contains text, equations, and captions, not figure images. Do not claim to have inspected page appearance.
|
|
18
|
+
- Return PDF annotations in the format Papilot supplies, with a single brief sentence summarizing the main priority. Do not repeat the findings in the summary or add a review essay. The author decides which suggestions to use in a separate editing task.
|
package/skills/latex-warnings.md
CHANGED
|
@@ -6,7 +6,7 @@ order: 6
|
|
|
6
6
|
---
|
|
7
7
|
Fix the problems LaTeX reports when it builds the paper.
|
|
8
8
|
|
|
9
|
-
-
|
|
9
|
+
- When the request lists problems from the last compilation, with their files and lines, fix those. Otherwise compile in your worktree (for example with `latexmk`) and read the log. If you cannot run commands, look for the same problems in the source instead.
|
|
10
10
|
- Fix undefined or duplicate labels, references and citations; overfull boxes, by rewording slightly or allowing a break where the line overflows; and package warnings that point to real mistakes.
|
|
11
11
|
- Do not silence warnings globally: no document-wide `\sloppy`, badness or tolerance changes, and no new packages, unless the request asks for them.
|
|
12
12
|
- In your summary, list the warnings you fixed and any you left, with the reason.
|
package/src/conversation.ts
CHANGED
|
@@ -6,6 +6,12 @@ export const reviewInstructions = 'Continue this task using the decisions and co
|
|
|
6
6
|
// Earlier versions stored the prompt itself as the author's message.
|
|
7
7
|
export const isFeedbackPrompt = (text: string) => text === reviewInstructions || text === 'Address the comments from this review round. Preserve accepted changes and do not reintroduce rejected edits.';
|
|
8
8
|
|
|
9
|
+
// Older AI reviews appended a generated copy of every PDF suggestion to the
|
|
10
|
+
// chat summary. Keep those records intact, but display only their summary.
|
|
11
|
+
export function aiReviewSummary(text: string) {
|
|
12
|
+
return text.split(/\n\n1\. \*\*Page \d+\*\* — /, 1)[0].trimEnd();
|
|
13
|
+
}
|
|
14
|
+
|
|
9
15
|
// What the agent receives when its own edits stop the review build. The chat
|
|
10
16
|
// shows the error, never this prompt.
|
|
11
17
|
export function buildFixPrompt(failure: BuildFailure) {
|
|
@@ -0,0 +1,354 @@
|
|
|
1
|
+
import { spawn, type ChildProcess } from 'node:child_process';
|
|
2
|
+
import { randomUUID } from 'node:crypto';
|
|
3
|
+
import { createInterface } from 'node:readline';
|
|
4
|
+
import fs from 'node:fs';
|
|
5
|
+
import os from 'node:os';
|
|
6
|
+
import path from 'node:path';
|
|
7
|
+
import type { AgentId } from '../shared.ts';
|
|
8
|
+
import { Adapter, findExecutable, type Item, type Thread, type Turn } from './agents.ts';
|
|
9
|
+
import { imageType } from './attachments.ts';
|
|
10
|
+
import { VERSION } from './paths.ts';
|
|
11
|
+
|
|
12
|
+
interface Choice { value: string; name: string }
|
|
13
|
+
interface ConfigOption { id: string; category?: string; currentValue: string; options: (Choice | { options: Choice[] })[] }
|
|
14
|
+
interface Session { sessionId: string; configOptions?: ConfigOption[]; modes?: { availableModes: { id: string }[] }; models?: { currentModelId: string; availableModels: { modelId: string; name: string }[] } }
|
|
15
|
+
interface LiveTurn { turn: Turn; cwd: string; text: string; message: string; tools: Map<string, any>; received: boolean; stopped: boolean }
|
|
16
|
+
// Some agents report every tool as kind "other" (DeepSeek Harness), so shell
|
|
17
|
+
// commands and file edits are also recognized by the tool's name.
|
|
18
|
+
const SHELL_TOOLS = new Set(['bash', 'shell', 'run_shell_command', 'execute_command', 'terminal']);
|
|
19
|
+
const EDIT_TOOLS = new Set(['write', 'edit', 'multiedit', 'strreplacefile', 'writefile', 'write_file', 'edit_file', 'replace', 'apply_patch']);
|
|
20
|
+
function toolKind(tool: any): string {
|
|
21
|
+
if (tool.kind && tool.kind !== 'other') return tool.kind;
|
|
22
|
+
const names = [tool.name, tool.title].filter(Boolean).map((name: string) => String(name).toLowerCase());
|
|
23
|
+
if (names.some(name => SHELL_TOOLS.has(name)) && tool.rawInput?.command) return 'execute';
|
|
24
|
+
if (names.some(name => EDIT_TOOLS.has(name))) return 'edit';
|
|
25
|
+
return tool.kind || 'other';
|
|
26
|
+
}
|
|
27
|
+
const choices = (option?: ConfigOption): Choice[] => (option?.options || []).flatMap(value => 'options' in value ? value.options : [value]);
|
|
28
|
+
|
|
29
|
+
// How one ACP agent is launched and kept on manual approvals. modes lists the
|
|
30
|
+
// session modes Papilot accepts, best first: a mode that edits the task
|
|
31
|
+
// worktree but asks before anything else. Papilot never inherits an
|
|
32
|
+
// auto-approve mode from the user's own configuration, so an agent that
|
|
33
|
+
// offers none of these modes is refused.
|
|
34
|
+
export interface AcpProfile {
|
|
35
|
+
id: AgentId; name: string; bin: string; binEnv: string; args: string[]; modes: string[]; setup: string;
|
|
36
|
+
env?: () => NodeJS.ProcessEnv;
|
|
37
|
+
}
|
|
38
|
+
export const ACP_PROFILES = {
|
|
39
|
+
kimi: { id: 'kimi', name: 'Kimi', bin: 'kimi', binEnv: 'PAPILOT_KIMI_BIN', args: ['acp'], modes: ['default'],
|
|
40
|
+
setup: 'Install Kimi Code CLI and run kimi login on this host, then select Reconnect. Set PAPILOT_KIMI_BIN if kimi is not on PATH.' },
|
|
41
|
+
qwen: { id: 'qwen', name: 'Qwen', bin: 'qwen', binEnv: 'PAPILOT_QWEN_BIN', args: ['--acp'], modes: ['auto-edit', 'default'],
|
|
42
|
+
setup: 'Install Qwen Code and sign in by running qwen once on this host, then select Reconnect. Set PAPILOT_QWEN_BIN if qwen is not on PATH.' },
|
|
43
|
+
// DeepSeek Harness has no session modes: its ACP profile sandboxes writes and
|
|
44
|
+
// commands to the session folder and asks before leaving it. Papilot pins
|
|
45
|
+
// that preset so a looser DSH_PERMISSION_MODE in the environment is not used.
|
|
46
|
+
deepseek: { id: 'deepseek', name: 'DeepSeek', bin: 'dsh', binEnv: 'PAPILOT_DSH_BIN', args: ['acp'], modes: [],
|
|
47
|
+
setup: 'Install DeepSeek Harness (npm install -g @deepseek-ai/dsh) and set up a model with dsh web or the dsh TUI, then select Reconnect. Set PAPILOT_DSH_BIN if dsh is not on PATH.',
|
|
48
|
+
env: () => ({ ...process.env, DSH_PERMISSION_MODE: 'workspace-write' }) },
|
|
49
|
+
// OpenCode runs tools without asking by default. Its highest-precedence
|
|
50
|
+
// config layer makes it ask before shell commands and anything outside the
|
|
51
|
+
// task worktree, while edits in the worktree go ahead for review.
|
|
52
|
+
opencode: { id: 'opencode', name: 'OpenCode', bin: 'opencode', binEnv: 'PAPILOT_OPENCODE_BIN', args: ['acp'], modes: ['build'],
|
|
53
|
+
setup: 'Install OpenCode and connect a provider with opencode auth login, then select Reconnect. Set PAPILOT_OPENCODE_BIN if opencode is not on PATH.',
|
|
54
|
+
env: () => ({ ...process.env, OPENCODE_CONFIG_CONTENT: opencodeConfig(process.env.OPENCODE_CONFIG_CONTENT) }) },
|
|
55
|
+
} satisfies Record<string, AcpProfile>;
|
|
56
|
+
export function opencodeConfig(own?: string) {
|
|
57
|
+
let config: Record<string, unknown> = {};
|
|
58
|
+
try { const parsed = own ? JSON.parse(own) : {}; if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) config = parsed; } catch { /* Replaced below. */ }
|
|
59
|
+
const permission = config.permission && typeof config.permission === 'object' ? config.permission : {};
|
|
60
|
+
return JSON.stringify({ ...config, permission: { ...permission, bash: 'ask', shell: 'ask', edit: 'allow', external_directory: 'ask' } });
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// ACP sessions are the thread IDs persisted by Papilot. The agent owns
|
|
64
|
+
// credentials and conversation storage; the adapter only translates JSON-RPC
|
|
65
|
+
// over stdio.
|
|
66
|
+
export class AcpAgent extends Adapter {
|
|
67
|
+
private child: ChildProcess | null = null;
|
|
68
|
+
private starting: Promise<void> | null = null;
|
|
69
|
+
private sequence = 0;
|
|
70
|
+
private pending = new Map<number, { resolve: (value: any) => void; reject: (error: Error) => void; timer?: NodeJS.Timeout }>();
|
|
71
|
+
private approvals = new Map<string, { rpcId: string | number; turn: Turn; options: { optionId: string; kind: string; name?: string }[]; question: boolean }>();
|
|
72
|
+
private live = new Map<string, LiveTurn>();
|
|
73
|
+
private sessions = new Map<string, Session>();
|
|
74
|
+
private capabilities: any = {};
|
|
75
|
+
private catalog: any[] = [];
|
|
76
|
+
private defaultModel: string | null = null;
|
|
77
|
+
private readonly name: string;
|
|
78
|
+
private readonly binary: string;
|
|
79
|
+
constructor(private profile: AcpProfile, binary?: string, private launch: typeof spawn = spawn) {
|
|
80
|
+
super();
|
|
81
|
+
this.name = profile.name;
|
|
82
|
+
this.binary = binary || process.env[profile.binEnv] || profile.bin;
|
|
83
|
+
}
|
|
84
|
+
detect() { return Boolean(findExecutable(this.binary)); }
|
|
85
|
+
|
|
86
|
+
start(): Promise<void> {
|
|
87
|
+
if (this.ready) return Promise.resolve();
|
|
88
|
+
if (this.starting) return this.starting;
|
|
89
|
+
this.starting = this.connect().finally(() => { this.starting = null; });
|
|
90
|
+
return this.starting;
|
|
91
|
+
}
|
|
92
|
+
private async connect() {
|
|
93
|
+
const cwd = fs.mkdtempSync(path.join(os.tmpdir(), `papilot-${this.profile.id}-`));
|
|
94
|
+
try {
|
|
95
|
+
// The probe folder is removed below, so the agent itself runs from the home folder.
|
|
96
|
+
const child = this.launch(findExecutable(this.binary) || this.binary, this.profile.args, { cwd: os.homedir(), env: this.profile.env?.() || process.env, stdio: ['pipe', 'pipe', 'pipe'] });
|
|
97
|
+
this.child = child;
|
|
98
|
+
const failed = (error: Error) => { if (this.child === child) this.disconnect(error); };
|
|
99
|
+
createInterface({ input: child.stdout! }).on('line', line => {
|
|
100
|
+
let message: any;
|
|
101
|
+
try { message = JSON.parse(line); } catch { return; }
|
|
102
|
+
if (this.child === child) this.receive(message);
|
|
103
|
+
});
|
|
104
|
+
child.stderr?.resume();
|
|
105
|
+
child.stdin?.on('error', failed);
|
|
106
|
+
child.once('error', failed);
|
|
107
|
+
child.once('close', (code, signal) => failed(new Error(`${this.name} exited (${signal || code}). ${this.profile.setup}`)));
|
|
108
|
+
// The first start of some agents installs their plugins, so allow it a minute.
|
|
109
|
+
const init = await this.rpc('initialize', { protocolVersion: 1, clientInfo: { name: 'papilot', version: VERSION }, clientCapabilities: {} }, 60000);
|
|
110
|
+
if (init.protocolVersion !== 1) throw new Error(`${this.name} returned an unsupported ACP version. Update it.`);
|
|
111
|
+
this.capabilities = init.agentCapabilities || {};
|
|
112
|
+
// No generation: a disposable session checks configuration and exposes models.
|
|
113
|
+
const probe: Session = await this.rpc('session/new', { cwd, mcpServers: [] }, 60000);
|
|
114
|
+
this.catalog = [];
|
|
115
|
+
if (this.profile.modes.length) this.mode(probe);
|
|
116
|
+
this.readModels(probe, true);
|
|
117
|
+
const sessionCapabilities = this.capabilities.sessionCapabilities || {};
|
|
118
|
+
if (sessionCapabilities.delete) await this.rpc('session/delete', { sessionId: probe.sessionId });
|
|
119
|
+
else if (sessionCapabilities.close) await this.rpc('session/close', { sessionId: probe.sessionId });
|
|
120
|
+
this.ready = true;
|
|
121
|
+
} catch (error) {
|
|
122
|
+
this.close();
|
|
123
|
+
throw new Error(`${this.name} is unavailable: ${error instanceof Error ? error.message.replace(/\.$/, '') : String(error)}. ${this.profile.setup}`);
|
|
124
|
+
} finally { fs.rmSync(cwd, { recursive: true, force: true }); }
|
|
125
|
+
}
|
|
126
|
+
protected account() { return { account: this.ready ? { type: `Local ${this.name} configuration` } : null, requiresOpenaiAuth: !this.ready }; }
|
|
127
|
+
protected models() { return { data: this.catalog }; }
|
|
128
|
+
|
|
129
|
+
async request(method: string, params: any = {}): Promise<any> {
|
|
130
|
+
if (method === 'thread/start' || method === 'thread/resume') {
|
|
131
|
+
const resumed = method === 'thread/resume';
|
|
132
|
+
const session = await this.session(resumed ? String(params.threadId) : null, params.cwd, params.model);
|
|
133
|
+
const known = this.threads.get(session.sessionId);
|
|
134
|
+
this.threads.set(session.sessionId, { cwd: params.cwd, instructions: params.developerInstructions || '', model: params.model || known?.model || null, started: resumed });
|
|
135
|
+
return { thread: { id: session.sessionId }, model: params.model || this.defaultModel };
|
|
136
|
+
}
|
|
137
|
+
return super.request(method, params);
|
|
138
|
+
}
|
|
139
|
+
private async session(id: string | null, cwd: string, model?: string | null): Promise<Session> {
|
|
140
|
+
let session = id ? this.sessions.get(id) : undefined;
|
|
141
|
+
if (!session) {
|
|
142
|
+
if (id && !this.capabilities.loadSession && !this.capabilities.sessionCapabilities?.resume) throw new Error(`This ${this.name} version cannot resume sessions. Update it.`);
|
|
143
|
+
const result = await this.rpc(id ? this.capabilities.sessionCapabilities?.resume ? 'session/resume' : 'session/load' : 'session/new', { ...(id ? { sessionId: id } : {}), cwd, mcpServers: [] });
|
|
144
|
+
session = { ...result, sessionId: id || result.sessionId };
|
|
145
|
+
if (!session!.sessionId) throw new Error(`${this.name} did not return a session ID.`);
|
|
146
|
+
// Do not inherit an auto-approve mode from the user's global configuration.
|
|
147
|
+
// Agents list modes as session modes or as a mode configuration option.
|
|
148
|
+
if (this.profile.modes.length) {
|
|
149
|
+
const { mode, option } = this.mode(session!);
|
|
150
|
+
if (session!.modes) await this.rpc('session/set_mode', { sessionId: session!.sessionId, modeId: mode });
|
|
151
|
+
else if (option!.currentValue !== mode) {
|
|
152
|
+
const result = await this.rpc('session/set_config_option', { sessionId: session!.sessionId, configId: option!.id, value: mode });
|
|
153
|
+
if (result?.configOptions) session!.configOptions = result.configOptions;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
this.sessions.set(session!.sessionId, session!);
|
|
157
|
+
}
|
|
158
|
+
if (model) {
|
|
159
|
+
const option = session!.configOptions?.find(option => option.category === 'model' || option.id === 'model');
|
|
160
|
+
if (option) {
|
|
161
|
+
const result = await this.rpc('session/set_config_option', { sessionId: session!.sessionId, configId: option.id, value: model });
|
|
162
|
+
if (result.configOptions) session!.configOptions = result.configOptions;
|
|
163
|
+
} else await this.rpc('session/set_model', { sessionId: session!.sessionId, modelId: model });
|
|
164
|
+
}
|
|
165
|
+
this.readModels(session!);
|
|
166
|
+
return session!;
|
|
167
|
+
}
|
|
168
|
+
// The accepted mode a session offers, as a session mode or a mode option.
|
|
169
|
+
private mode(session: Session) {
|
|
170
|
+
const option = session.configOptions?.find(option => option.category === 'mode' || option.id === 'mode');
|
|
171
|
+
const offered = session.modes ? session.modes.availableModes.map(mode => mode.id) : choices(option).map(choice => choice.value);
|
|
172
|
+
const mode = this.profile.modes.find(id => offered.includes(id));
|
|
173
|
+
if (!mode) throw new Error(`${this.name} does not offer manual approvals. Update it.`);
|
|
174
|
+
return { mode, option };
|
|
175
|
+
}
|
|
176
|
+
private readModels(session: Session, initial = false) {
|
|
177
|
+
const model = session.configOptions?.find(option => option.category === 'model' || option.id === 'model');
|
|
178
|
+
const thinking = session.configOptions?.find(option => option.category === 'thought_level' || option.id === 'thinking');
|
|
179
|
+
// A "default" level is the model default Papilot already offers.
|
|
180
|
+
const levels = choices(thinking).filter(choice => choice.value !== 'default'), level = thinking?.currentValue === 'default' ? null : thinking?.currentValue || null;
|
|
181
|
+
const current = model?.currentValue || session.models?.currentModelId || null;
|
|
182
|
+
if (initial) this.defaultModel = current;
|
|
183
|
+
const available = model ? choices(model) : (session.models?.availableModels || []).map(model => ({ value: model.modelId, name: model.name }));
|
|
184
|
+
for (const option of available) {
|
|
185
|
+
const previous = this.catalog.find(entry => entry.model === option.value);
|
|
186
|
+
const entry = { model: option.value, displayName: option.name, isDefault: option.value === this.defaultModel,
|
|
187
|
+
supportedReasoningEfforts: option.value === current ? levels.map(choice => ({ reasoningEffort: choice.value })) : previous?.supportedReasoningEfforts || [],
|
|
188
|
+
defaultReasoningEffort: option.value === current ? level : previous?.defaultReasoningEffort || null };
|
|
189
|
+
if (previous) Object.assign(previous, entry); else this.catalog.push(entry);
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
protected run(turn: Turn, thread: Thread, text: string, effort: string | null, images: string[]) { void this.execute(turn, thread, text, effort, images); }
|
|
193
|
+
private async execute(turn: Turn, thread: Thread, text: string, effort: string | null, images: string[]) {
|
|
194
|
+
let stopped = false, prompting = false;
|
|
195
|
+
let cancelTimer: NodeJS.Timeout | undefined;
|
|
196
|
+
const live: LiveTurn = { turn, cwd: thread.cwd, text: '', message: randomUUID(), tools: new Map(), received: false, stopped: false };
|
|
197
|
+
turn.stop = () => {
|
|
198
|
+
stopped = true; live.stopped = true;
|
|
199
|
+
this.clearApprovals(turn);
|
|
200
|
+
if (prompting) {
|
|
201
|
+
this.send({ method: 'session/cancel', params: { sessionId: turn.threadId } });
|
|
202
|
+
// A broken server must not leave paused tasks waiting forever.
|
|
203
|
+
cancelTimer ??= setTimeout(() => this.disconnect(new Error(`${this.name} did not acknowledge cancellation. Reconnect to resume.`)), 5000);
|
|
204
|
+
cancelTimer.unref();
|
|
205
|
+
}
|
|
206
|
+
};
|
|
207
|
+
try {
|
|
208
|
+
const session = await this.session(turn.threadId, thread.cwd, thread.model);
|
|
209
|
+
if (effort) {
|
|
210
|
+
const option = session.configOptions?.find(option => option.category === 'thought_level' || option.id === 'thinking');
|
|
211
|
+
if (!option || !choices(option).some(choice => choice.value === effort)) throw new Error(`${this.name} does not support reasoning effort ${effort} for this model.`);
|
|
212
|
+
await this.rpc('session/set_config_option', { sessionId: turn.threadId, configId: option.id, value: effort });
|
|
213
|
+
}
|
|
214
|
+
const prompt: any[] = [{ type: 'text', text: thread.started ? text : `${thread.instructions}\n\n---\n\n${text}` }];
|
|
215
|
+
if (images.length && !this.capabilities.promptCapabilities?.image) throw new Error(`This ${this.name} version does not support image input. Update it, or send the images to another agent.`);
|
|
216
|
+
for (const file of images) {
|
|
217
|
+
const bytes = await fs.promises.readFile(file), mimeType = imageType(bytes);
|
|
218
|
+
if (!mimeType) throw new Error('This image format is not supported.');
|
|
219
|
+
prompt.push({ type: 'image', data: bytes.toString('base64'), mimeType });
|
|
220
|
+
}
|
|
221
|
+
if (stopped) return this.finish(turn, 'interrupted');
|
|
222
|
+
this.live.set(turn.threadId, live);
|
|
223
|
+
prompting = true;
|
|
224
|
+
// The response arrives at the end of the turn; user approvals can take any amount of time.
|
|
225
|
+
const result = await this.rpc('session/prompt', { sessionId: turn.threadId, prompt }, 0);
|
|
226
|
+
this.threads.set(turn.threadId, { ...thread, started: true });
|
|
227
|
+
this.flush(live);
|
|
228
|
+
if (stopped || result.stopReason === 'cancelled') this.finish(turn, 'interrupted');
|
|
229
|
+
else if (result.stopReason === 'end_turn' && live.received) this.finish(turn, 'completed');
|
|
230
|
+
else if (result.stopReason === 'end_turn') this.finish(turn, 'failed', `${this.name} returned no response. Check its login and model configuration, then retry.`);
|
|
231
|
+
else this.finish(turn, 'failed', `${this.name} stopped: ${result.stopReason || 'no stop reason returned'}.`);
|
|
232
|
+
} catch (error) {
|
|
233
|
+
this.flush(live);
|
|
234
|
+
this.finish(turn, stopped ? 'interrupted' : 'failed', stopped ? undefined : error instanceof Error ? error.message : String(error));
|
|
235
|
+
} finally {
|
|
236
|
+
if (cancelTimer) clearTimeout(cancelTimer);
|
|
237
|
+
this.clearApprovals(turn);
|
|
238
|
+
this.live.delete(turn.threadId);
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
private flush(live: LiveTurn) {
|
|
242
|
+
if (live.text.trim()) this.item(live.turn, { id: live.message, type: 'agentMessage', text: live.text });
|
|
243
|
+
live.text = ''; live.message = randomUUID();
|
|
244
|
+
}
|
|
245
|
+
private update(params: any) {
|
|
246
|
+
const live = this.live.get(params.sessionId), update = params.update;
|
|
247
|
+
if (!live || !update) return; // Includes history replay while loading a session.
|
|
248
|
+
if (update.sessionUpdate === 'agent_message_chunk' && update.content?.type === 'text') {
|
|
249
|
+
const text = String(update.content.text || ''), shown = live.text.trim();
|
|
250
|
+
live.text += text;
|
|
251
|
+
// Empty and whitespace-only chunks (Qwen sends them) would show as empty
|
|
252
|
+
// messages, so text is streamed from its first visible character.
|
|
253
|
+
if (!live.text.trim()) return;
|
|
254
|
+
live.received = true;
|
|
255
|
+
this.notify('item/agentMessage/delta', { threadId: live.turn.threadId, turnId: live.turn.id, itemId: live.message, delta: shown ? text : live.text });
|
|
256
|
+
} else if (update.sessionUpdate === 'tool_call' || update.sessionUpdate === 'tool_call_update') {
|
|
257
|
+
live.received = true;
|
|
258
|
+
this.flush(live);
|
|
259
|
+
const tool = { ...live.tools.get(update.toolCallId), ...update };
|
|
260
|
+
live.tools.set(update.toolCallId, tool);
|
|
261
|
+
const kind = toolKind(tool), named = tool.rawInput?.file_path || tool.rawInput?.path;
|
|
262
|
+
const paths = (tool.locations || []).map((location: any) => location.path).concat((tool.content || []).filter((part: any) => part.type === 'diff').map((part: any) => part.path))
|
|
263
|
+
.concat(kind === 'edit' && typeof named === 'string' ? [path.resolve(live.cwd, named)] : []);
|
|
264
|
+
const id = `${live.turn.id}:${tool.toolCallId}`;
|
|
265
|
+
const status = tool.status === 'failed' ? 'failed' : tool.status === 'completed' ? 'completed' : 'inProgress';
|
|
266
|
+
const output = (tool.content || []).map((part: any) => part.type === 'content' && part.content?.type === 'text' ? part.content.text : '').join('\n');
|
|
267
|
+
const item: Item = kind === 'execute' ? { id, type: 'commandExecution', command: tool.rawInput?.command || tool.title || `${this.name} command`, aggregatedOutput: output.slice(-40000), status }
|
|
268
|
+
: kind === 'edit' && paths.length ? { id, type: 'fileChange', changes: [...new Set(paths)].map(file => ({ path: file })), status }
|
|
269
|
+
: { id, type: 'toolCall', text: tool.title || `${this.name} tool`, status };
|
|
270
|
+
this.item(live.turn, item, status !== 'inProgress');
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
private receive(message: any) {
|
|
274
|
+
if (!message.method) {
|
|
275
|
+
const wait = this.pending.get(message.id);
|
|
276
|
+
if (!wait) return;
|
|
277
|
+
if (wait.timer) clearTimeout(wait.timer);
|
|
278
|
+
this.pending.delete(message.id);
|
|
279
|
+
if (message.error) wait.reject(new Error(message.error.message || `${this.name} request failed.`));
|
|
280
|
+
else wait.resolve(message.result);
|
|
281
|
+
return;
|
|
282
|
+
}
|
|
283
|
+
if (message.method === 'session/update') { this.update(message.params); return; }
|
|
284
|
+
if (message.id === undefined) return;
|
|
285
|
+
const live = this.live.get(message.params?.sessionId);
|
|
286
|
+
if (message.method !== 'session/request_permission' || !live || live.turn.status !== 'inProgress') {
|
|
287
|
+
this.send({ id: message.id, error: { code: -32601, message: 'This client does not support this request, or the turn has ended.' } }); return;
|
|
288
|
+
}
|
|
289
|
+
if (live.stopped) { this.send({ id: message.id, result: { outcome: { outcome: 'cancelled' } } }); return; }
|
|
290
|
+
const id = randomUUID(), update = message.params.toolCall || {};
|
|
291
|
+
const tool = { ...live.tools.get(update.toolCallId), ...update };
|
|
292
|
+
const file = tool.locations?.[0]?.path || tool.rawInput?.file_path || tool.rawInput?.path;
|
|
293
|
+
const edit = toolKind(tool) === 'edit';
|
|
294
|
+
const options = message.params.options || [];
|
|
295
|
+
const question = options.filter((option: any) => option.kind === 'allow_once').length > 1;
|
|
296
|
+
this.approvals.set(id, { rpcId: message.id, turn: live.turn, options, question });
|
|
297
|
+
if (question) {
|
|
298
|
+
// A question arrives as a permission request with several one-time
|
|
299
|
+
// choices (Kimi's AskUserQuestion): the question itself is the tool's
|
|
300
|
+
// text, and a one-time rejection is its Skip choice. An ACP permission
|
|
301
|
+
// answer can only select an option, never carry free text.
|
|
302
|
+
const asked = (tool.content || []).map((part: any) => part.type === 'content' && part.content?.type === 'text' ? part.content.text : '').filter(Boolean).join('\n');
|
|
303
|
+
this.emit('request', { id, method: 'item/tool/requestUserInput', params: { threadId: live.turn.threadId, turnId: live.turn.id,
|
|
304
|
+
questions: [{ id: 'choice', question: asked || tool.title || `${this.name} needs your input.`, isOther: false, options: options.filter((option: any) => option.kind === 'allow_once' || option.kind === 'reject_once').map((option: any) => ({ label: option.name || option.optionId })) }] } });
|
|
305
|
+
return;
|
|
306
|
+
}
|
|
307
|
+
this.emit('request', { id, method: edit ? 'item/fileChange/requestApproval' : 'item/commandExecution/requestApproval',
|
|
308
|
+
params: { threadId: live.turn.threadId, turnId: live.turn.id, reason: tool.title || `${this.name} requests permission.`, command: tool.rawInput?.command || (edit && file ? `Edit ${file}` : tool.title || `${this.name} tool`) } });
|
|
309
|
+
}
|
|
310
|
+
respond(id: string | number, result: any) {
|
|
311
|
+
const wait = this.approvals.get(String(id));
|
|
312
|
+
if (!wait) throw new Error('This request has ended.');
|
|
313
|
+
const kind = result?.decision === 'accept' ? 'allow_once' : result?.decision === 'decline' ? 'reject_once' : null;
|
|
314
|
+
const answer = result?.answers?.choice?.answers?.[0];
|
|
315
|
+
const option = wait.question
|
|
316
|
+
? wait.options.find(option => (option.kind === 'allow_once' || option.kind === 'reject_once') && (option.name || option.optionId) === answer)
|
|
317
|
+
: wait.options.find(option => option.kind === kind);
|
|
318
|
+
this.approvals.delete(String(id));
|
|
319
|
+
this.send({ id: wait.rpcId, result: { outcome: option ? { outcome: 'selected', optionId: option.optionId } : { outcome: 'cancelled' } } });
|
|
320
|
+
}
|
|
321
|
+
reject(id: string | number, _message: string) { if (this.approvals.has(String(id))) this.respond(id, { decision: 'decline' }); }
|
|
322
|
+
private clearApprovals(turn: Turn) {
|
|
323
|
+
for (const [id, wait] of this.approvals) if (wait.turn === turn) {
|
|
324
|
+
this.respond(id, { decision: 'cancel' });
|
|
325
|
+
this.notify('serverRequest/resolved', { threadId: turn.threadId, requestId: id });
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
private send(message: Record<string, unknown>) {
|
|
329
|
+
const child = this.child;
|
|
330
|
+
child?.stdin?.write(JSON.stringify({ jsonrpc: '2.0', ...message }) + '\n', error => { if (error && this.child === child) this.disconnect(error); });
|
|
331
|
+
}
|
|
332
|
+
private rpc(method: string, params: unknown, timeout = 20000): Promise<any> {
|
|
333
|
+
if (!this.child) return Promise.reject(new Error(`${this.name} is not connected.`));
|
|
334
|
+
const id = ++this.sequence;
|
|
335
|
+
return new Promise((resolve, reject) => {
|
|
336
|
+
const timer = timeout ? setTimeout(() => { this.pending.delete(id); reject(new Error(`${this.name} request timed out: ${method}.`)); }, timeout) : undefined;
|
|
337
|
+
this.pending.set(id, { resolve, reject, timer });
|
|
338
|
+
this.send({ id, method, params });
|
|
339
|
+
});
|
|
340
|
+
}
|
|
341
|
+
private disconnect(error: Error, notify = true) {
|
|
342
|
+
const child = this.child;
|
|
343
|
+
this.child = null; this.ready = false; this.sessions.clear();
|
|
344
|
+
for (const wait of this.pending.values()) { if (wait.timer) clearTimeout(wait.timer); wait.reject(error); }
|
|
345
|
+
this.pending.clear(); this.approvals.clear();
|
|
346
|
+
child?.stdin?.end(); child?.kill('SIGTERM');
|
|
347
|
+
if (child && notify) this.emit('disconnected', error);
|
|
348
|
+
}
|
|
349
|
+
close() { this.disconnect(new Error(`${this.name} disconnected.`), false); }
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
export class KimiAgent extends AcpAgent {
|
|
353
|
+
constructor(binary?: string, launch?: typeof spawn) { super(ACP_PROFILES.kimi, binary, launch); }
|
|
354
|
+
}
|