papilot 0.3.0 → 0.4.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 +2 -0
- package/README.zh-CN.md +2 -0
- package/bin/doctor.mjs +11 -0
- package/dist/assets/{index-Dq3s-vhH.css → index-BB0zC0wj.css} +1 -1
- package/dist/assets/index-DTScOfwx.js +206 -0
- package/dist/assets/{math-BTm2hvOz.js → math-C3k2egGJ.js} +7 -7
- package/dist/assets/{pdf-view-BEn1EztO.js → pdf-view-Df0q8hSk.js} +1 -1
- package/dist/index.html +2 -2
- package/package.json +4 -2
- package/src/notes.ts +1 -1
- package/src/server/access.ts +170 -0
- package/src/server/accounts.ts +226 -0
- package/src/server/app.ts +298 -0
- package/src/server/frontend.ts +1 -1
- package/src/server/main.ts +21 -223
- package/src/server/manuscript.ts +4 -4
- package/src/server/paths.ts +4 -0
- package/src/server/security.ts +20 -0
- package/src/server/store.ts +5 -0
- package/src/server/workspace.ts +47 -17
- package/src/shared.ts +15 -3
- package/vendor/latex-review/src/render/pdf.ts +35 -1
- package/dist/assets/index-BkowXmKr.js +0 -206
package/src/server/manuscript.ts
CHANGED
|
@@ -210,13 +210,13 @@ export async function workingTree(root: string) {
|
|
|
210
210
|
}
|
|
211
211
|
|
|
212
212
|
async function engine() {
|
|
213
|
-
const [{ Builder }, { Repo }, { loadProject }, { mupdf, PdfDocument, containsCenter }] = await Promise.all([
|
|
213
|
+
const [{ Builder }, { Repo }, { loadProject }, { mupdf, PdfDocument, containsCenter, walkText }] = await Promise.all([
|
|
214
214
|
import(pathToFileURL(path.join(ENGINE, 'src/core/build.ts')).href),
|
|
215
215
|
import(pathToFileURL(path.join(ENGINE, 'src/core/git.ts')).href),
|
|
216
216
|
import(pathToFileURL(path.join(ENGINE, 'src/core/config.ts')).href),
|
|
217
217
|
import(pathToFileURL(path.join(ENGINE, 'src/render/pdf.ts')).href),
|
|
218
218
|
]);
|
|
219
|
-
return { Builder, Repo, loadProject, mupdf, PdfDocument, containsCenter };
|
|
219
|
+
return { Builder, Repo, loadProject, mupdf, PdfDocument, containsCenter, walkText };
|
|
220
220
|
}
|
|
221
221
|
|
|
222
222
|
// `synctex edit` maps a PDF position (big points from the top-left corner) to
|
|
@@ -293,7 +293,7 @@ export function sourceExcerpt(file: string, [start, end]: [number, number], limi
|
|
|
293
293
|
// the page's top left. pdf.js lays a whole line out in a stand-in font and
|
|
294
294
|
// drifts from the printed words mid-line; per-word runs cannot drift.
|
|
295
295
|
export async function pdfWords(pdf: string, page: number) {
|
|
296
|
-
const { PdfDocument } = await engine();
|
|
296
|
+
const { PdfDocument, walkText } = await engine();
|
|
297
297
|
const document = new PdfDocument(pdf);
|
|
298
298
|
try {
|
|
299
299
|
if (!Number.isInteger(page) || page < 0 || page >= document.length) throw Object.assign(new Error('That page is not part of the manuscript.'), { statusCode: 400 });
|
|
@@ -301,7 +301,7 @@ export async function pdfWords(pdf: string, page: number) {
|
|
|
301
301
|
const words: { text: string; left: number; right: number; baseline: number; size: number; eol: boolean }[] = [];
|
|
302
302
|
let word: (typeof words)[number] | null = null;
|
|
303
303
|
const text = raw.toStructuredText('preserve-whitespace');
|
|
304
|
-
text
|
|
304
|
+
walkText(text, {
|
|
305
305
|
beginLine: () => { word = null; },
|
|
306
306
|
onChar: (c: string, origin: [number, number], _font: unknown, size: number, quad: number[]) => {
|
|
307
307
|
if (/\s/u.test(c)) { word = null; return; }
|
package/src/server/paths.ts
CHANGED
|
@@ -8,4 +8,8 @@ export const VERSION: string = JSON.parse(fs.readFileSync(path.join(ROOT, 'packa
|
|
|
8
8
|
export const PORT = Number(process.env.PAPILOT_PORT || 7274);
|
|
9
9
|
export const PUBLIC_ORIGIN = process.env.PAPILOT_PUBLIC_ORIGIN || '';
|
|
10
10
|
export const OWNER = process.env.PAPILOT_TAILSCALE_LOGIN || '';
|
|
11
|
+
// The public URL where invited people sign in with passkeys, and the local
|
|
12
|
+
// port its proxy (such as Tailscale Funnel) forwards to.
|
|
13
|
+
export const ACCOUNT_ORIGIN = (process.env.PAPILOT_ACCOUNT_ORIGIN || '').replace(/\/+$/, '');
|
|
14
|
+
export const ACCOUNT_PORT = Number(process.env.PAPILOT_ACCOUNT_PORT || 7275);
|
|
11
15
|
export const CODEX = process.env.PAPILOT_CODEX_BIN || (fs.existsSync(path.join(ROOT, 'node_modules/.bin/codex')) ? path.join(ROOT, 'node_modules/.bin/codex') : 'codex');
|
package/src/server/security.ts
CHANGED
|
@@ -13,3 +13,23 @@ export function authorize(headers: IncomingHttpHeaders, remoteAddress: string |
|
|
|
13
13
|
if (headers.origin && headers.origin !== publicOrigin) return 'The request origin does not match.';
|
|
14
14
|
return null;
|
|
15
15
|
}
|
|
16
|
+
|
|
17
|
+
// The public sign-in URL needs its own port, an exact HTTPS origin (plain HTTP
|
|
18
|
+
// only on localhost, for testing), and must not be the tailnet endpoint, whose
|
|
19
|
+
// requests are trusted as the owner.
|
|
20
|
+
export function accountProblem(accountOrigin: string, accountPort: number, port: number, publicOrigin: string): string | null {
|
|
21
|
+
if (!accountOrigin) return null;
|
|
22
|
+
let url: URL;
|
|
23
|
+
try { url = new URL(accountOrigin); } catch { return `PAPILOT_ACCOUNT_ORIGIN is not a URL: ${accountOrigin}`; }
|
|
24
|
+
if (url.origin !== accountOrigin) return `PAPILOT_ACCOUNT_ORIGIN must be an origin without a path, such as ${url.origin}.`;
|
|
25
|
+
if (url.protocol !== 'https:' && !(url.protocol === 'http:' && url.hostname === 'localhost')) return 'PAPILOT_ACCOUNT_ORIGIN must use https://.';
|
|
26
|
+
if (!Number.isInteger(accountPort) || accountPort < 1 || accountPort > 65535 || accountPort === port) return `PAPILOT_ACCOUNT_PORT must be a free port other than ${port}.`;
|
|
27
|
+
if (publicOrigin && new URL(publicOrigin).host === url.host) return 'PAPILOT_ACCOUNT_ORIGIN must differ from PAPILOT_PUBLIC_ORIGIN, the tailnet address; use another port, such as :10000.';
|
|
28
|
+
return null;
|
|
29
|
+
}
|
|
30
|
+
// HSTS on the public address covers every port of its host name, so it is
|
|
31
|
+
// left off when the tailnet endpoint on that host uses plain HTTP.
|
|
32
|
+
export function accountHsts(accountOrigin: string, publicOrigin: string) {
|
|
33
|
+
const account = new URL(accountOrigin);
|
|
34
|
+
return account.protocol === 'https:' && !(publicOrigin.startsWith('http:') && new URL(publicOrigin).hostname === account.hostname);
|
|
35
|
+
}
|
package/src/server/store.ts
CHANGED
|
@@ -17,6 +17,11 @@ export class Store extends EventEmitter {
|
|
|
17
17
|
CREATE TABLE IF NOT EXISTS items(id TEXT PRIMARY KEY, task_id TEXT NOT NULL, created INTEGER NOT NULL, data TEXT NOT NULL);
|
|
18
18
|
CREATE TABLE IF NOT EXISTS deleted_tasks(id TEXT PRIMARY KEY, deleted INTEGER NOT NULL);
|
|
19
19
|
CREATE TABLE IF NOT EXISTS annotations(id TEXT PRIMARY KEY, project_id TEXT NOT NULL, data TEXT NOT NULL);
|
|
20
|
+
CREATE TABLE IF NOT EXISTS users(id TEXT PRIMARY KEY, data TEXT NOT NULL);
|
|
21
|
+
CREATE TABLE IF NOT EXISTS credentials(id TEXT PRIMARY KEY, user_id TEXT NOT NULL, data TEXT NOT NULL);
|
|
22
|
+
CREATE TABLE IF NOT EXISTS invites(token_hash TEXT PRIMARY KEY, user_id TEXT NOT NULL, expires INTEGER NOT NULL, data TEXT NOT NULL);
|
|
23
|
+
CREATE TABLE IF NOT EXISTS sessions(token_hash TEXT PRIMARY KEY, id TEXT UNIQUE NOT NULL, user_id TEXT NOT NULL, expires INTEGER NOT NULL, data TEXT NOT NULL);
|
|
24
|
+
CREATE TABLE IF NOT EXISTS members(project_id TEXT NOT NULL, user_id TEXT NOT NULL, PRIMARY KEY(project_id, user_id));
|
|
20
25
|
CREATE INDEX IF NOT EXISTS task_items ON items(task_id,created);
|
|
21
26
|
CREATE INDEX IF NOT EXISTS task_runs ON runs(task_id,status);`);
|
|
22
27
|
}
|
package/src/server/workspace.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { randomUUID } from 'node:crypto';
|
|
1
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
2
2
|
import { execFile } from 'node:child_process';
|
|
3
3
|
import { promisify } from 'node:util';
|
|
4
4
|
import fs from 'node:fs';
|
|
@@ -13,7 +13,7 @@ import { findSkill, type SkillFile } from './skills.ts';
|
|
|
13
13
|
import { buildFixPrompt, reviewInstructions, skillPrompt, summarizeFeedback } from '../conversation.ts';
|
|
14
14
|
import { commitMessageFrom } from '../commit.ts';
|
|
15
15
|
import { commitChanges, conflictMarkers, gitCommit, gitRemote, gitStatus, mergeConflicts, mergeInProgress } from './manuscript.ts';
|
|
16
|
-
import type { Task, Run, TimelineItem, PendingRequest, RuntimeStatus, Snapshot, PendingReview, AgentModel, Usage, AgentId, MessageMeta, FeedbackSummary, BuildFailure } from '../shared.ts';
|
|
16
|
+
import type { Task, Run, TimelineItem, PendingRequest, RuntimeStatus, Snapshot, PendingReview, AgentModel, Usage, AgentId, MessageMeta, FeedbackSummary, BuildFailure, Author } from '../shared.ts';
|
|
17
17
|
|
|
18
18
|
const execute = promisify(execFile);
|
|
19
19
|
const delay = (ms: number) => new Promise(resolve => setTimeout(resolve, ms));
|
|
@@ -43,6 +43,10 @@ const FIXABLE = /^(Some edits are outside the task scope|The revised document co
|
|
|
43
43
|
const BUILD_FIX = 'build-fix-';
|
|
44
44
|
|
|
45
45
|
const AGENTS: AgentId[] = ['codex', 'claude', 'antigravity'];
|
|
46
|
+
// The small model an agent offers for short text such as a commit message,
|
|
47
|
+
// by the names agents give them (GPT Luna and mini, Claude Haiku, Gemini Flash).
|
|
48
|
+
const LIGHT = /\b(?:luna|mini|nano|haiku|flash|lite)\b/i;
|
|
49
|
+
export const lightModel = (models: AgentModel[]) => models.find(model => LIGHT.test(model.id) || LIGHT.test(model.name));
|
|
46
50
|
// A commit message's agent sees the changes in its prompt and needs no tools.
|
|
47
51
|
const commitInstructions = 'You write Git commit messages for the repository of a LaTeX paper. Reply with the commit message only, without a preamble, quotes or a code fence: a subject line in the imperative mood under 72 characters and, only when the change needs more explanation, a blank line and a body of a few short plain sentences. Describe what changed in the paper, such as its wording, sections, equations, figures or references, rather than listing files. Follow the style and language of the recent commit subjects, or write in English when there are none. Everything you need is in the message: do not run commands, read files or edit anything.';
|
|
48
52
|
// A merge's agent edits the conflicted files in the manuscript checkout and nothing else.
|
|
@@ -72,6 +76,9 @@ export class Workspace extends EventEmitter {
|
|
|
72
76
|
private flushTimer: NodeJS.Timeout | null = null;
|
|
73
77
|
private connecting: Promise<void> | null = null;
|
|
74
78
|
private usageTimers = new Map<AgentId, NodeJS.Timeout>();
|
|
79
|
+
// Commit messages by agent, model and prompt, kept or still being written,
|
|
80
|
+
// so one written ahead for a finished review is there when the dialog opens.
|
|
81
|
+
private messages = new Map<string, Promise<{ message: string; agent: AgentId }>>();
|
|
75
82
|
constructor(public store: Store, public codex: AgentRuntime = new Codex(), private concurrency = 2, private connectReview = reviewClient, others: Partial<Record<AgentId, AgentRuntime>> = {}) {
|
|
76
83
|
super();
|
|
77
84
|
this.agents = { codex, claude: others.claude || new ClaudeAgent(), antigravity: others.antigravity || new AntigravityAgent() };
|
|
@@ -137,10 +144,12 @@ export class Workspace extends EventEmitter {
|
|
|
137
144
|
this.store.saveProject(project); return project;
|
|
138
145
|
}
|
|
139
146
|
// A task can start from a skill; its instructions are then the author's own words, possibly none.
|
|
140
|
-
async createTask(input: { projectId: string; title: string; instructions: string; skill?: string; agent?: AgentId; model?: string; effort?: string; id: string }) {
|
|
147
|
+
async createTask(input: { projectId: string; title: string; instructions: string; skill?: string; agent?: AgentId; model?: string; effort?: string; id: string; author?: Author }) {
|
|
141
148
|
if (!/^[\w-]{16,64}$/.test(input.id)) throw new Error('Invalid task ID.');
|
|
142
149
|
if (this.store.taskDeleted(input.id)) throw new Error('This task was deleted. Create a new task instead.');
|
|
143
|
-
|
|
150
|
+
// A retried request returns its task; an ID from another paper is refused.
|
|
151
|
+
const existing = this.store.tasks().find(t => t.id === input.id);
|
|
152
|
+
if (existing) { if (existing.projectId !== input.projectId) throw new Error('Invalid task ID.'); return existing; }
|
|
144
153
|
if (this.preparing.has(input.id)) throw new Error('This task is being created. Refresh in a moment.');
|
|
145
154
|
this.preparing.add(input.id);
|
|
146
155
|
try {
|
|
@@ -153,14 +162,14 @@ export class Workspace extends EventEmitter {
|
|
|
153
162
|
worktree: info.worktree, threadId: null, turnId: null, agent: input.agent || 'codex', model: input.model || null, effort: input.effort || null, status: 'idle',
|
|
154
163
|
createdAt: Date.now(), updatedAt: Date.now(), feedbackCursor: 0, error: null, review: null };
|
|
155
164
|
this.store.saveTask(task);
|
|
156
|
-
await this.enqueue(task.id, input.instructions, 'initial-' + task.id, false, { skill });
|
|
165
|
+
await this.enqueue(task.id, input.instructions, 'initial-' + task.id, false, { skill, author: input.author });
|
|
157
166
|
return this.store.task(task.id);
|
|
158
167
|
} finally { this.preparing.delete(input.id); }
|
|
159
168
|
}
|
|
160
169
|
// The author's message, a skill run with or without one, the review
|
|
161
170
|
// feedback alone, or a build the agent broke. The chat keeps what happened;
|
|
162
171
|
// the agent gets the prompt.
|
|
163
|
-
async enqueue(taskId: string, text: string, requestKey: string, includeFeedback = true, options: { skill?: SkillFile; feedback?: boolean; build?: BuildFailure } = {}) {
|
|
172
|
+
async enqueue(taskId: string, text: string, requestKey: string, includeFeedback = true, options: { skill?: SkillFile; feedback?: boolean; build?: BuildFailure; author?: Author } = {}) {
|
|
164
173
|
const task = this.store.task(taskId);
|
|
165
174
|
if (task.status === 'deleting') throw new Error('This task is being deleted.');
|
|
166
175
|
const existing = this.store.runByKey(requestKey);
|
|
@@ -179,7 +188,7 @@ export class Workspace extends EventEmitter {
|
|
|
179
188
|
if (task.error) prompt += '\n\nLatest task/review error:\n' + task.error + '\nThe latest accepted manuscript is at ' + this.store.project(task.projectId).path + '. You may read it to understand accepted changes, but must only edit your isolated task worktree. Prepare a revised proposal for another review; never write to or accept changes in the manuscript checkout.';
|
|
180
189
|
}
|
|
181
190
|
const run: Run = { id: randomUUID(), taskId, requestKey, prompt, feedbackCursor: cursor, status: 'queued', createdAt: Date.now(), finishedAt: null };
|
|
182
|
-
const meta: MessageMeta = { ...(options.skill ? { skill: { id: options.skill.id, name: options.skill.name } } : {}), ...(summary ? { feedback: summary } : {}), ...(options.feedback ? { sendsFeedback: true } : {}), ...(options.build ? { build: options.build } : {}) };
|
|
191
|
+
const meta: MessageMeta = { ...(options.skill ? { skill: { id: options.skill.id, name: options.skill.name } } : {}), ...(summary ? { feedback: summary } : {}), ...(options.feedback ? { sendsFeedback: true } : {}), ...(options.build ? { build: options.build } : {}), ...(options.author ? { author: options.author } : {}) };
|
|
183
192
|
const saved = this.store.transaction(() => {
|
|
184
193
|
// Feedback loading yields to other requests. Recheck identity, queue, and
|
|
185
194
|
// task status together before committing; none of the earlier reads own a lock.
|
|
@@ -535,13 +544,13 @@ ${this.agentOf(task) === 'antigravity' ? 'Shell commands are not available to yo
|
|
|
535
544
|
}
|
|
536
545
|
// The agent that writes a commit message or resolves a merge: the one the
|
|
537
546
|
// author chose, or else the first that is signed in, with the chosen model
|
|
538
|
-
// or the default one.
|
|
539
|
-
private helper(choice: { agent?: AgentId; model?: string }, task: string) {
|
|
547
|
+
// or the default one; light asks for the agent's small model when it has one.
|
|
548
|
+
private helper(choice: { agent?: AgentId; model?: string }, task: string, light = false) {
|
|
540
549
|
const usable = (id: AgentId) => this.agents[id].ready && this.runtimes[id].authenticated;
|
|
541
550
|
const id = choice.agent && usable(choice.agent) ? choice.agent : AGENTS.find(usable);
|
|
542
551
|
if (!id) throw new Error(`Sign in to Codex, Claude or Antigravity to ${task}.`);
|
|
543
552
|
const models = this.runtimes[id].models;
|
|
544
|
-
return { id, model: models.find(item => item.id === choice.model) || models.find(item => item.isDefault) };
|
|
553
|
+
return { id, model: (light ? lightModel(models) : undefined) || models.find(item => item.id === choice.model) || models.find(item => item.isDefault) };
|
|
545
554
|
}
|
|
546
555
|
// One turn on a thread the agent does not keep, outside any task: its
|
|
547
556
|
// reply is returned and no chat shows it.
|
|
@@ -568,9 +577,11 @@ ${this.agentOf(task) === 'antigravity' ? 'Shell commands are not available to yo
|
|
|
568
577
|
});
|
|
569
578
|
}
|
|
570
579
|
// Writes a commit message for the chosen manuscript files, like Cursor's
|
|
571
|
-
// generate button: one read-only turn
|
|
572
|
-
|
|
573
|
-
|
|
580
|
+
// generate button: one read-only turn on the agent's small model, with the
|
|
581
|
+
// lightest reasoning it offers. The same changes get the message already
|
|
582
|
+
// written, or being written, for them unless the author asks for a fresh one.
|
|
583
|
+
async commitMessage(projectId: string, paths: string[], choice: { agent?: AgentId; fresh?: boolean } = {}) {
|
|
584
|
+
const project = this.store.project(projectId), { id, model } = this.helper(choice, 'write a commit message', true);
|
|
574
585
|
const { files, patch, recent } = await commitChanges(project.path, paths);
|
|
575
586
|
const tasks = this.store.tasks().filter(task => task.projectId === projectId && task.review?.files?.some(file => paths.includes(file))).map(task => task.title);
|
|
576
587
|
const prompt = [
|
|
@@ -579,10 +590,29 @@ ${this.agentOf(task) === 'antigravity' ? 'Shell commands are not available to yo
|
|
|
579
590
|
`Changed files:\n${files.map(file => `${file.status === '?' ? 'A' : file.status} ${file.from ? `${file.from} -> ` : ''}${file.path}`).join('\n')}`,
|
|
580
591
|
`Diff:\n${patch}`,
|
|
581
592
|
].filter(Boolean).join('\n\n');
|
|
582
|
-
const
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
593
|
+
const key = createHash('sha256').update(`${id}\0${model?.id ?? ''}\0${prompt}`).digest('hex'), kept = this.messages.get(key);
|
|
594
|
+
if (kept && !choice.fresh) return kept;
|
|
595
|
+
const written = this.oneOff(id, { cwd: project.path, sandbox: 'read-only', instructions: commitInstructions, prompt, model, effort: model?.efforts[0], timeout: 120000, what: 'write a commit message' }).then(reply => {
|
|
596
|
+
const message = commitMessageFrom(reply);
|
|
597
|
+
if (!message) throw new Error(`${AGENT_NAMES[id]} did not return a commit message. Try again.`);
|
|
598
|
+
return { message, agent: id };
|
|
599
|
+
});
|
|
600
|
+
// Only the latest few messages are kept, and never a failed one.
|
|
601
|
+
this.messages.delete(key); this.messages.set(key, written);
|
|
602
|
+
for (const old of this.messages.keys()) { if (this.messages.size <= 20) break; this.messages.delete(old); }
|
|
603
|
+
written.catch(() => { if (this.messages.get(key) === written) this.messages.delete(key); });
|
|
604
|
+
return written;
|
|
605
|
+
}
|
|
606
|
+
// A finished review offers to commit what it accepted, so after the
|
|
607
|
+
// author's last decision its commit message is written ahead.
|
|
608
|
+
async prepareCommitMessage(taskId: string) {
|
|
609
|
+
try {
|
|
610
|
+
const task = this.store.task(taskId), files = task.review?.files || [];
|
|
611
|
+
if (task.status !== 'reviewing' || task.review?.counts?.pending || !files.length) return;
|
|
612
|
+
const project = this.store.project(task.projectId), git = await gitStatus(project.path);
|
|
613
|
+
const paths = git.merging ? [] : files.filter(file => git.files.some(item => item.path === file));
|
|
614
|
+
if (paths.length) await this.commitMessage(project.id, paths, { agent: this.agentOf(task) });
|
|
615
|
+
} catch { /* The commit dialog writes the message when it opens. */ }
|
|
586
616
|
}
|
|
587
617
|
// Resolves a merge's conflicts in the manuscript checkout itself: the agent
|
|
588
618
|
// edits only the conflicted files, at the model's default reasoning, and
|
package/src/shared.ts
CHANGED
|
@@ -20,7 +20,9 @@ export interface TimelineItem { id: string; taskId: string; type: string; text:
|
|
|
20
20
|
// A user message can run a skill or carry review feedback; the chat shows
|
|
21
21
|
// those as what they are rather than as the prompt the agent receives.
|
|
22
22
|
// Papilot itself sends the agent a build its own edits broke.
|
|
23
|
-
export interface MessageMeta { skill?: { id: string; name: string }; feedback?: FeedbackSummary; sendsFeedback?: boolean; build?: BuildFailure }
|
|
23
|
+
export interface MessageMeta { skill?: { id: string; name: string }; feedback?: FeedbackSummary; sendsFeedback?: boolean; build?: BuildFailure; author?: Author }
|
|
24
|
+
// Who wrote a message or note; none means the owner.
|
|
25
|
+
export interface Author { id: string; name: string }
|
|
24
26
|
// A failed review build as the engine reports it; log is the compiler log's path.
|
|
25
27
|
export interface BuildFailure { error: string; details: string[]; log?: string }
|
|
26
28
|
export interface FeedbackSummary { round: number | null; accepted: number; rejected: number; undone: number; comments: { where: string; text: string }[]; buildError?: boolean }
|
|
@@ -32,11 +34,21 @@ export interface AgentModel { id: string; name: string; isDefault: boolean; effo
|
|
|
32
34
|
export interface UsageWindow { usedPercent: number; minutes: number | null; resetsAt: number | null }
|
|
33
35
|
export interface Usage { plan: string | null; windows: UsageWindow[]; credits: { unlimited: boolean; balance: number | null } | null; reached: boolean }
|
|
34
36
|
export interface RuntimeStatus { connected: boolean; authenticated: boolean; account: string | null; error: string | null; models: AgentModel[]; usage?: Usage | null }
|
|
35
|
-
export interface Snapshot { projects: Project[]; tasks: Task[]; requests: PendingRequest[]; runtime: RuntimeStatus; runtimes?: Partial<Record<AgentId, RuntimeStatus>>; version: string }
|
|
37
|
+
export interface Snapshot { projects: Project[]; tasks: Task[]; requests: PendingRequest[]; runtime: RuntimeStatus; runtimes?: Partial<Record<AgentId, RuntimeStatus>>; version: string; viewer?: Viewer }
|
|
38
|
+
// Who is looking: the owner on this host or Tailscale (local), or a person
|
|
39
|
+
// signed in with a passkey at the public URL (account). Older servers send none.
|
|
40
|
+
// owner names the owner, whose messages carry no author.
|
|
41
|
+
export interface Viewer { id: string; name: string; role: 'owner' | 'member'; listener: 'local' | 'account'; owner: string }
|
|
42
|
+
// A person the owner invited, as the People dialog shows them. joined means
|
|
43
|
+
// they have registered at least one passkey; projects are the shared papers.
|
|
44
|
+
export interface Person { id: string; name: string; role: 'owner' | 'member'; joined: boolean; projects: string[]; lastSeenAt: number | null; devices: Device[] }
|
|
45
|
+
// A passkey or a signed-in browser session, either of which the owner can revoke.
|
|
46
|
+
export interface Device { id: string; kind: 'passkey' | 'session'; label: string; createdAt: number; lastUsedAt: number | null }
|
|
47
|
+
export interface People { origin: string | null; people: Person[] }
|
|
36
48
|
export interface GitFile { path: string; status: string; staged: boolean; lines?: [number, number]; from?: string }
|
|
37
49
|
// merging is a merge in progress, with the files whose conflict markers remain.
|
|
38
50
|
export interface GitStatus { branch: string | null; upstream: string | null; ahead: number; behind: number; files: GitFile[]; head: { hash: string; subject: string; date: number } | null; remote?: boolean; merging?: { message: string; conflicts: string[] } | null }
|
|
39
51
|
export interface GitDiff { path: string; diff: string; truncated: boolean }
|
|
40
52
|
export interface ManuscriptBuild { status: 'building' | 'ready' | 'failed'; key?: string; pages?: [number, number][]; builtAt?: number; error?: string; current: boolean }
|
|
41
|
-
export interface Annotation { id: string; projectId: string; key: string; page: number; rect: [number, number, number, number]; rects?: [number, number, number, number][]; comment: string; quote: string; source: { file: string; lines: [number, number]; excerpt?: string } | null; createdAt: number; taskId?: string }
|
|
53
|
+
export interface Annotation { id: string; projectId: string; key: string; page: number; rect: [number, number, number, number]; rects?: [number, number, number, number][]; comment: string; quote: string; source: { file: string; lines: [number, number]; excerpt?: string } | null; createdAt: number; taskId?: string; author?: Author }
|
|
42
54
|
export interface Manuscript { git: GitStatus; build: ManuscriptBuild; annotations: Annotation[] }
|
|
@@ -54,6 +54,40 @@ export interface Raster {
|
|
|
54
54
|
stride: number;
|
|
55
55
|
pixels: Uint8Array;
|
|
56
56
|
}
|
|
57
|
+
// mupdf.js's walk turns each character into a string with String.fromCharCode,
|
|
58
|
+
// which keeps only the low 16 bits: math italic 𝐹 (U+1D439) arrives as the
|
|
59
|
+
// Hangul syllable 퐹 (U+D439). The JSON export spells every line correctly, one
|
|
60
|
+
// code point per character, so a character comes from there whenever the two
|
|
61
|
+
// agree on its low 16 bits.
|
|
62
|
+
export function walkText(
|
|
63
|
+
text: mupdf.StructuredText,
|
|
64
|
+
walker: Parameters<mupdf.StructuredText["walk"]>[0],
|
|
65
|
+
) {
|
|
66
|
+
const lines = (
|
|
67
|
+
JSON.parse(text.asJSON()) as { blocks: { lines?: { text: string }[] }[] }
|
|
68
|
+
).blocks.flatMap((b) => (b.lines ?? []).map((l) => Array.from(l.text)));
|
|
69
|
+
let next = 0,
|
|
70
|
+
line: string[] = [],
|
|
71
|
+
index = 0;
|
|
72
|
+
text.walk({
|
|
73
|
+
...walker,
|
|
74
|
+
beginLine: (...args) => {
|
|
75
|
+
line = lines[next++] ?? [];
|
|
76
|
+
index = 0;
|
|
77
|
+
walker.beginLine?.(...args);
|
|
78
|
+
},
|
|
79
|
+
onChar: (c, ...args) => {
|
|
80
|
+
const full = line[index++];
|
|
81
|
+
walker.onChar?.(
|
|
82
|
+
full !== undefined &&
|
|
83
|
+
(full.codePointAt(0)! & 0xffff) === c.charCodeAt(0)
|
|
84
|
+
? full
|
|
85
|
+
: c,
|
|
86
|
+
...args,
|
|
87
|
+
);
|
|
88
|
+
},
|
|
89
|
+
});
|
|
90
|
+
}
|
|
57
91
|
export class PdfPage {
|
|
58
92
|
private columns?: boolean;
|
|
59
93
|
raw: mupdf.Page;
|
|
@@ -73,7 +107,7 @@ export class PdfPage {
|
|
|
73
107
|
"preserve-whitespace,preserve-ligatures",
|
|
74
108
|
);
|
|
75
109
|
let block: TextBlock, line: Line;
|
|
76
|
-
text
|
|
110
|
+
walkText(text, {
|
|
77
111
|
beginTextBlock: (rect) => {
|
|
78
112
|
block = { rect, text: "", lines: [] };
|
|
79
113
|
this.blocks.push(block);
|